From e5ca23b403dee30189a06d51f80a197351aef609 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Fri, 22 May 2026 16:09:36 +0800 Subject: [PATCH 1/2] feature flag provider index offset --- .../AzureAppConfigurationExtensions.cs | 8 +- .../AzureAppConfigurationOptions.cs | 130 ++++++- .../AzureAppConfigurationSource.cs | 13 +- .../FeatureManagement/FeatureFlagOptions2.cs | 130 +++++++ .../FeatureFlagRefreshOptions2.cs | 30 ++ .../FeatureManagementConstants.cs | 6 + .../FeatureManagementKeyValueAdapter.cs | 27 +- .../Unit/FeatureManagementTests.cs | 353 ++++++++++++++++++ 8 files changed, 690 insertions(+), 7 deletions(-) create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions2.cs create mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagRefreshOptions2.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs index a5657c2b5..3690e3f41 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using System; using System.Collections.Generic; +using System.Linq; namespace Microsoft.Extensions.Configuration { @@ -96,7 +97,12 @@ public static IConfigurationBuilder AddAzureAppConfiguration( { if (!_isProviderDisabled) { - configurationBuilder.Add(new AzureAppConfigurationSource(action, optional)); + // Count Azure App Configuration sources already registered on this builder so that + // the new source can pick a feature flag index offset that avoids colliding with + // flags emitted by those earlier sources. + int priorAppConfigSourceCount = configurationBuilder.Sources.OfType().Count(); + + configurationBuilder.Add(new AzureAppConfigurationSource(action, optional, priorAppConfigSourceCount)); } return configurationBuilder; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index e5b6e2585..4c6dafc45 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -149,6 +149,22 @@ internal IEnumerable Adapters /// internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing(); + /// + /// The starting index used when emitting feature flags into the configuration system under + /// the Microsoft schema (feature_management:feature_flags:<index>). + /// + /// During migration from classic to new Azure App Configuration feature flags, multiple + /// providers may write to the same configuration section. .NET's configuration system + /// merges arrays by index position, causing flags from different providers to overwrite + /// each other. To prevent this, the provider automatically assigns an offset based on the + /// number of other Azure App Configuration providers already registered on the + /// configuration builder: the first provider uses offset 0, the second uses + /// , the + /// third uses 2 * , + /// and so on. + /// + internal int FeatureFlagIndexOffset { get; set; } = 0; + /// /// Options used to configure provider startup. /// @@ -168,7 +184,7 @@ public AzureAppConfigurationOptions() { new AzureKeyVaultKeyValueAdapter(new AzureKeyVaultSecretProvider()), new JsonKeyValueAdapter(), - new FeatureManagementKeyValueAdapter(FeatureFlagTracing) + new FeatureManagementKeyValueAdapter(FeatureFlagTracing, this) }; // Adds the default query to App Configuration if and are never called. @@ -422,6 +438,118 @@ public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCre return this; } + /// + /// Connect the provider to the Azure App Configuration service via a connection string. This + /// is the entry point for the new feature flag experience; see . + /// Equivalent in connection behavior to . + /// + /// Used to authenticate with Azure App Configuration. + public AzureAppConfigurationOptions ConfigureConnection(string connectionString) + => Connect(connectionString); + + /// + /// Connect the provider to an Azure App Configuration store and its replicas via a list of + /// connection strings. This is the entry point for the new feature flag experience; see + /// . Equivalent in connection behavior to + /// . + /// + /// Used to authenticate with Azure App Configuration. + public AzureAppConfigurationOptions ConfigureConnection(IEnumerable connectionStrings) + => Connect(connectionStrings); + + /// + /// Connect the provider to Azure App Configuration using endpoint and token credentials. This + /// is the entry point for the new feature flag experience; see . + /// Equivalent in connection behavior to . + /// + /// The endpoint of the Azure App Configuration to connect to. + /// Token credentials to use to connect. + public AzureAppConfigurationOptions ConfigureConnection(Uri endpoint, TokenCredential credential) + => Connect(endpoint, credential); + + /// + /// Connect the provider to an Azure App Configuration store and its replicas using a list of + /// endpoints and a token credential. This is the entry point for the new feature flag + /// experience; see . Equivalent in connection behavior to + /// . + /// + /// The list of endpoints to connect to. + /// Token credential to use to connect. + public AzureAppConfigurationOptions ConfigureConnection(IEnumerable endpoints, TokenCredential credential) + => Connect(endpoints, credential); + + /// + /// Configures the new Azure App Configuration feature flag experience. Unlike + /// , callers must opt in explicitly by setting + /// to true, and refresh must be enabled + /// separately via . + /// + /// A callback used to configure feature flag options. + public AzureAppConfigurationOptions ConfigureFeatureFlags(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var options = new FeatureFlagOptions2(); + configure(options); + + // Opt-in: if the caller did not explicitly enable feature flags this call is a no-op. + if (!options.Enabled) + { + return this; + } + + if (options.FeatureFlagSelectors.Count != 0 && options.Label != null) + { + throw new InvalidOperationException( + $"Please select feature flags by either the {nameof(FeatureFlagOptions2.Select)} method or by setting the {nameof(FeatureFlagOptions2.Label)} property, not both."); + } + + // Default selector when none is supplied: load all flags under the configured label. + if (options.FeatureFlagSelectors.Count == 0) + { + options.FeatureFlagSelectors.Add(new KeyValueSelector + { + KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", + LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, + IsFeatureFlagSelector = true + }); + } + + // Refresh is opt-in on the new API. If refresh was not configured, or was configured + // with Enabled = false, no watcher is registered. + FeatureFlagRefreshOptions2 refresh = options.Refresh; + bool refreshEnabled = refresh != null && refresh.Enabled; + + if (refreshEnabled && refresh.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) + { + throw new ArgumentOutOfRangeException( + nameof(FeatureFlagRefreshOptions2.RefreshInterval), + refresh.RefreshInterval.TotalMilliseconds, + string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); + } + + foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) + { + _selectors.AppendUnique(featureFlagSelector); + + if (refreshEnabled) + { + _ffWatchers.AppendUnique(new KeyValueWatcher + { + Key = featureFlagSelector.KeyFilter, + Label = featureFlagSelector.LabelFilter, + Tags = featureFlagSelector.TagFilters, + RefreshInterval = refresh.RefreshInterval + }); + } + } + + 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..0ea511de0 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs @@ -3,6 +3,7 @@ // using Azure.Data.AppConfiguration; using Microsoft.Extensions.Azure; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; using System; using System.Collections.Generic; using System.Linq; @@ -14,12 +15,22 @@ internal class AzureAppConfigurationSource : IConfigurationSource private readonly bool _optional; private readonly Func _optionsProvider; - public AzureAppConfigurationSource(Action optionsInitializer, bool optional = false) + public AzureAppConfigurationSource(Action optionsInitializer, bool optional = false, int priorAppConfigSourceCount = 0) { _optionsProvider = () => { var options = new AzureAppConfigurationOptions(); optionsInitializer(options); + + // If the caller didn't explicitly set an offset, derive it from the position of this + // source relative to other Azure App Configuration sources on the configuration + // builder. This avoids index collisions when multiple Azure App Configuration + // providers emit feature flags under the Microsoft schema. + if (options.FeatureFlagIndexOffset == 0 && priorAppConfigSourceCount > 0) + { + options.FeatureFlagIndexOffset = priorAppConfigSourceCount * FeatureManagementConstants.FeatureFlagIndexStride; + } + return options; }; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions2.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions2.cs new file mode 100644 index 000000000..5f413b06e --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions2.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; +using System; +using System.Collections.Generic; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + /// + /// Options used to configure feature flag loading via + /// , the new feature flag + /// experience offered by Azure App Configuration. + /// + /// + /// Draft name. Will be renamed prior to GA of the new feature flag experience. + /// In contrast to , this options type is fully opt-in: + /// must be set to true for any feature flags to be loaded, and + /// refresh must be enabled separately via . + /// + public class FeatureFlagOptions2 + { + /// + /// Whether feature flag loading is enabled for this provider. Defaults to false; + /// the new API requires the caller to opt in to feature flag loading explicitly. + /// + public bool Enabled { get; set; } + + /// + /// The label that feature flags will be selected from when no explicit + /// calls are made. Mutually + /// exclusive with . + /// + public string Label { get; set; } + + /// + /// A collection of selectors describing which feature flags to load. + /// + internal List FeatureFlagSelectors { get; } = new List(); + + /// + /// Refresh options configured via . null if refresh + /// has not been configured. + /// + internal FeatureFlagRefreshOptions2 Refresh { get; private set; } + + /// + /// Specifies the feature flags to include in the configuration provider. + /// can be called multiple times + /// to include multiple sets of feature flags. + /// + /// + /// The filter to apply to feature flag names. An asterisk (*) may be added to the end to + /// match by prefix. The characters asterisk (*), comma (,), and backslash (\) are + /// reserved and must be escaped with a backslash (\). + /// + /// + /// The label filter to apply. Defaults to the null label. The characters asterisk (*) and + /// comma (,) are not supported. + /// + /// + /// Optional tag filters of the form "tagName=tagValue". Up to five tag filters may be + /// supplied. + /// + public FeatureFlagOptions2 Select(string featureFlagFilter = KeyFilter.Any, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) + { + if (string.IsNullOrEmpty(featureFlagFilter)) + { + throw new ArgumentNullException(nameof(featureFlagFilter)); + } + + if (featureFlagFilter.EndsWith(@"\*")) + { + throw new ArgumentException(@"Feature flag filter should not end with '\*'.", nameof(featureFlagFilter)); + } + + if (string.IsNullOrWhiteSpace(labelFilter)) + { + labelFilter = LabelFilter.Null; + } + + if (labelFilter.Contains("*") || labelFilter.Contains(",")) + { + throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); + } + + if (tagFilters != null) + { + foreach (string tag in tagFilters) + { + if (string.IsNullOrEmpty(tag) || !tag.Contains("=") || tag.IndexOf('=') == 0) + { + throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagFilters)); + } + } + } + + string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter; + + FeatureFlagSelectors.AppendUnique(new KeyValueSelector + { + KeyFilter = featureFlagPrefix, + LabelFilter = labelFilter, + TagFilters = tagFilters, + IsFeatureFlagSelector = true + }); + + return this; + } + + /// + /// Configures refresh behavior for the feature flags loaded by this provider. + /// + /// A callback used to configure refresh options. + public FeatureFlagOptions2 ConfigureRefresh(Action configure) + { + if (configure == null) + { + throw new ArgumentNullException(nameof(configure)); + } + + var refreshOptions = new FeatureFlagRefreshOptions2(); + configure(refreshOptions); + Refresh = refreshOptions; + + return this; + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagRefreshOptions2.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagRefreshOptions2.cs new file mode 100644 index 000000000..8e4939aae --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagRefreshOptions2.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement +{ + /// + /// Options used to configure refresh for feature flags loaded via + /// . + /// + /// + /// Draft name. Will be renamed prior to GA of the new feature flag experience. + /// + public class FeatureFlagRefreshOptions2 + { + /// + /// Whether feature flag refresh is enabled. Defaults to false; the new API requires + /// the caller to opt in to background refresh explicitly. + /// + public bool Enabled { get; set; } + + /// + /// The minimum time interval between consecutive refresh operations for feature flags. + /// Must be greater than or equal to . + /// Defaults to . + /// + public TimeSpan RefreshInterval { get; set; } = RefreshConstants.DefaultFeatureFlagRefreshInterval; + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs index d344896ad..4a5a25183 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs @@ -13,6 +13,12 @@ internal class FeatureManagementConstants public const string FeatureManagementSectionName = "feature_management"; public const string FeatureFlagsSectionName = "feature_flags"; + // Stride used when offsetting feature flag indices to avoid collisions between + // multiple Azure App Configuration providers writing to the same configuration + // section. The Nth provider on a configuration builder uses an offset of + // N * FeatureFlagIndexStride when emitting flags under the Microsoft schema. + public const int FeatureFlagIndexStride = 10000; + // Feature flag properties public const string Id = "id"; public const string Enabled = "enabled"; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index fdd7f2fdf..11ec66134 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -20,12 +20,15 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManage internal class FeatureManagementKeyValueAdapter : IKeyValueAdapter { private FeatureFlagTracing _featureFlagTracing; + private readonly AzureAppConfigurationOptions _options; private int _featureFlagIndex = 0; + private bool _warnedAboutIndexStrideOverflow = false; private bool _fmSchemaCompatibilityDisabled = false; - public FeatureManagementKeyValueAdapter(FeatureFlagTracing featureFlagTracing) + public FeatureManagementKeyValueAdapter(FeatureFlagTracing featureFlagTracing, AzureAppConfigurationOptions options) { _featureFlagTracing = featureFlagTracing ?? throw new ArgumentNullException(nameof(featureFlagTracing)); + _options = options ?? throw new ArgumentNullException(nameof(options)); _fmSchemaCompatibilityDisabled = EnvironmentVariableHelper.GetBoolOrDefault(EnvironmentVariableNames.FmSchemacompatibilityDisabled); } @@ -42,7 +45,7 @@ public Task>> ProcessKeyValue(Configura featureFlag.Allocation != null || featureFlag.Telemetry != null) { - keyValues = ProcessMicrosoftSchemaFeatureFlag(featureFlag, setting, endpoint); + keyValues = ProcessMicrosoftSchemaFeatureFlag(featureFlag, setting, endpoint, logger); } else { @@ -83,6 +86,7 @@ public void OnChangeDetected(ConfigurationSetting setting = null) public void OnConfigUpdated() { _featureFlagIndex = 0; + _warnedAboutIndexStrideOverflow = false; return; } @@ -140,7 +144,7 @@ private List> ProcessDotnetSchemaFeatureFlag(Featur return keyValues; } - private List> ProcessMicrosoftSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint) + private List> ProcessMicrosoftSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint, Logger logger) { var keyValues = new List>(); @@ -149,10 +153,25 @@ private List> ProcessMicrosoftSchemaFeatureFlag(Fea return keyValues; } - string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{_featureFlagIndex}"; + int absoluteIndex = _options.FeatureFlagIndexOffset + _featureFlagIndex; + + string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{absoluteIndex}"; _featureFlagIndex++; + // Warn once when this provider has emitted more flags than the offset stride can + // accommodate; further flags will collide with the next provider's offset slot. + if (!_warnedAboutIndexStrideOverflow && _featureFlagIndex >= FeatureManagementConstants.FeatureFlagIndexStride) + { + logger?.LogWarning( + $"Azure App Configuration provider emitted {_featureFlagIndex} feature flags, which meets or exceeds " + + $"the per-provider stride of {FeatureManagementConstants.FeatureFlagIndexStride}. Feature flags from " + + $"different Azure App Configuration providers may collide in the configuration system. " + + $"Consider reducing the number of feature flags loaded per provider."); + + _warnedAboutIndexStrideOverflow = true; + } + keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Id}", featureFlag.Id)); keyValues.Add(new KeyValuePair($"{featureFlagPath}:{FeatureManagementConstants.Enabled}", featureFlag.Enabled.ToString())); diff --git a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs index 439c63bb6..d9fbc6456 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs @@ -2360,6 +2360,359 @@ public void EnvironmentVariableForcesMicrosoftSchemaForAllFlags() Assert.Equal("Big", configWithoutEnvVar["feature_management:feature_flags:0:variants:0:name"]); } + [Fact] + public void FeatureFlagIndexOffset_SingleProvider_UsesNoOffset() + { + // Regression check: a single Azure App Configuration provider should still emit + // feature flags starting at index 0 under the Microsoft schema. + var mockClient = new Mock(MockBehavior.Strict); + + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { CreateMicrosoftSchemaFlag("FlagA") })); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.UseFeatureFlags(); + }) + .Build(); + + Assert.Equal("FlagA", config["feature_management:feature_flags:0:id"]); + Assert.Null(config[$"feature_management:feature_flags:{FeatureManagementConstants.FeatureFlagIndexStride}:id"]); + } + + [Fact] + public void FeatureFlagIndexOffset_TwoProviders_SecondUsesStrideOffset() + { + // When two Azure App Configuration providers are registered on the same builder, the + // second one should offset its feature flag indices by FeatureFlagIndexStride so its + // flags do not collide with the first provider's flags during array merging. + var classicMock = new Mock(MockBehavior.Strict); + classicMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("ClassicFlagA"), + CreateMicrosoftSchemaFlag("ClassicFlagB") + })); + + var newMock = new Mock(MockBehavior.Strict); + newMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("NewFlagA"), + CreateMicrosoftSchemaFlag("NewFlagB") + })); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(classicMock.Object); + options.UseFeatureFlags(); + }) + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(newMock.Object); + options.UseFeatureFlags(); + }) + .Build(); + + // Classic provider keeps indices 0, 1 + Assert.Equal("ClassicFlagA", config["feature_management:feature_flags:0:id"]); + Assert.Equal("ClassicFlagB", config["feature_management:feature_flags:1:id"]); + + // New provider shifted to indices 10000, 10001 + int stride = FeatureManagementConstants.FeatureFlagIndexStride; + Assert.Equal("NewFlagA", config[$"feature_management:feature_flags:{stride}:id"]); + Assert.Equal("NewFlagB", config[$"feature_management:feature_flags:{stride + 1}:id"]); + } + + [Fact] + public void FeatureFlagIndexOffset_ThreeProviders_IncrementingOffsets() + { + // Verify the offset scales linearly with provider registration position so that an + // arbitrary number of Azure App Configuration providers can coexist without index + // collisions, up to the per-provider stride. + ConfigurationClient MakeClient(string flagId) + { + var mock = new Mock(MockBehavior.Strict); + mock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List { CreateMicrosoftSchemaFlag(flagId) })); + return mock.Object; + } + + ConfigurationClient client0 = MakeClient("Flag0"); + ConfigurationClient client1 = MakeClient("Flag1"); + ConfigurationClient client2 = MakeClient("Flag2"); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(client0); + options.UseFeatureFlags(); + }) + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(client1); + options.UseFeatureFlags(); + }) + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(client2); + options.UseFeatureFlags(); + }) + .Build(); + + int stride = FeatureManagementConstants.FeatureFlagIndexStride; + Assert.Equal("Flag0", config["feature_management:feature_flags:0:id"]); + Assert.Equal("Flag1", config[$"feature_management:feature_flags:{stride}:id"]); + Assert.Equal("Flag2", config[$"feature_management:feature_flags:{2 * stride}:id"]); + } + + [Fact] + public void FeatureFlagIndexOffset_DuplicateFlagIds_LaterProviderWins() + { + // Document and lock in the "new flag wins on duplicate" behavior that customers rely + // on during migration. Because the second provider's indices come after the first's, + // the Feature Management library's LastOrDefault resolution picks the second. + var classicMock = new Mock(MockBehavior.Strict); + classicMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("Beta", enabled: false) + })); + + var newMock = new Mock(MockBehavior.Strict); + newMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("Beta", enabled: true) + })); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(classicMock.Object); + options.UseFeatureFlags(); + }) + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(newMock.Object); + options.UseFeatureFlags(); + }) + .Build(); + + int stride = FeatureManagementConstants.FeatureFlagIndexStride; + + // Both providers emit the flag at distinct indices. + Assert.Equal("Beta", config["feature_management:feature_flags:0:id"]); + Assert.Equal("False", config["feature_management:feature_flags:0:enabled"]); + Assert.Equal("Beta", config[$"feature_management:feature_flags:{stride}:id"]); + Assert.Equal("True", config[$"feature_management:feature_flags:{stride}:enabled"]); + } + + [Fact] + public void ConfigureFeatureFlags_NotEnabled_NoFlagsLoaded() + { + // The new API is fully opt-in: omitting fo.Enabled = true must not register any + // feature flag selectors or watchers, even if Select() was called. + var options = new AzureAppConfigurationOptions(); + int baseSelectorCount = options.Selectors.Count(); + + options.ConfigureFeatureFlags(fo => + { + // Enabled is intentionally left false. + fo.Select(); + fo.ConfigureRefresh(ro => + { + ro.Enabled = true; + ro.RefreshInterval = TimeSpan.FromMinutes(1); + }); + }); + + Assert.Equal(baseSelectorCount, options.Selectors.Count()); + Assert.Empty(options.FeatureFlagWatchers); + } + + [Fact] + public void ConfigureFeatureFlags_Enabled_LoadsFlagsUnderMicrosoftSchema() + { + var mockClient = new Mock(MockBehavior.Strict); + mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("NewFlagA"), + CreateMicrosoftSchemaFlag("NewFlagB") + })); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.ConfigureFeatureFlags(fo => + { + fo.Enabled = true; + fo.Select(); + }); + }) + .Build(); + + Assert.Equal("NewFlagA", config["feature_management:feature_flags:0:id"]); + Assert.Equal("NewFlagB", config["feature_management:feature_flags:1:id"]); + } + + [Fact] + public void ConfigureFeatureFlags_ClassicProviderFirst_NewProviderApplyOffset() + { + // classic provider registered first via + // UseFeatureFlags, new provider registered second via ConfigureFeatureFlags. The + // new provider must pick up the FeatureFlagIndexStride offset automatically so its + // flags do not collide with the classic provider's. + var classicMock = new Mock(MockBehavior.Strict); + classicMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("ClassicFlagA"), + CreateMicrosoftSchemaFlag("ClassicFlagB") + })); + + var newMock = new Mock(MockBehavior.Strict); + newMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("NewFlagA"), + CreateMicrosoftSchemaFlag("NewFlagB") + })); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(classicMock.Object); + options.UseFeatureFlags(); + }) + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(newMock.Object); + options.ConfigureFeatureFlags(fo => + { + fo.Enabled = true; + fo.Select(); + }); + }) + .Build(); + + // Classic provider keeps the low indices. + Assert.Equal("ClassicFlagA", config["feature_management:feature_flags:0:id"]); + Assert.Equal("ClassicFlagB", config["feature_management:feature_flags:1:id"]); + + // New provider shifted past the stride. + int stride = FeatureManagementConstants.FeatureFlagIndexStride; + Assert.Equal("NewFlagA", config[$"feature_management:feature_flags:{stride}:id"]); + Assert.Equal("NewFlagB", config[$"feature_management:feature_flags:{stride + 1}:id"]); + } + + [Fact] + public void ConfigureFeatureFlags_DuplicateFlagId_NewProviderWins() + { + // The "register new provider after classic" guidance hinges on the new flag + // overriding the classic flag when ids collide. This locks in that behavior. + var classicMock = new Mock(MockBehavior.Strict); + classicMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("Beta", enabled: false) + })); + + var newMock = new Mock(MockBehavior.Strict); + newMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) + .Returns(new MockAsyncPageable(new List + { + CreateMicrosoftSchemaFlag("Beta", enabled: true) + })); + + var config = new ConfigurationBuilder() + .AddAzureAppConfiguration(options => + { + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(classicMock.Object); + options.UseFeatureFlags(); + }) + .AddAzureAppConfiguration(options => + { + options.ConfigureConnection("Endpoint=https://example.azconfig.io;Id=Foo;Secret=Zm9v"); + options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(newMock.Object); + options.ConfigureFeatureFlags(fo => + { + fo.Enabled = true; + fo.Select(); + }); + }) + .Build(); + + int stride = FeatureManagementConstants.FeatureFlagIndexStride; + + // Both occurrences are emitted at distinct indices so the Feature Management library's + // LastOrDefault deduplication picks the newer flag. + Assert.Equal("Beta", config["feature_management:feature_flags:0:id"]); + Assert.Equal("False", config["feature_management:feature_flags:0:enabled"]); + Assert.Equal("Beta", config[$"feature_management:feature_flags:{stride}:id"]); + Assert.Equal("True", config[$"feature_management:feature_flags:{stride}:enabled"]); + } + + [Fact] + public void ConfigureFeatureFlags_LabelAndSelectMutuallyExclusive_Throws() + { + // Mirror the existing UseFeatureFlags validation: callers cannot mix Label with Select. + var options = new AzureAppConfigurationOptions(); + + var ex = Assert.Throws(() => + options.ConfigureFeatureFlags(fo => + { + fo.Enabled = true; + fo.Label = "prod"; + fo.Select("MyApp*"); + })); + + Assert.Contains(nameof(FeatureFlagOptions2.Select), ex.Message); + Assert.Contains(nameof(FeatureFlagOptions2.Label), ex.Message); + } + + [Fact] + public void ConfigureFeatureFlags_RefreshIntervalTooShort_Throws() + { + // Refresh validation parity with UseFeatureFlags: a sub-minimum interval must throw. + var options = new AzureAppConfigurationOptions(); + + Assert.Throws(() => + options.ConfigureFeatureFlags(fo => + { + fo.Enabled = true; + fo.ConfigureRefresh(ro => + { + ro.Enabled = true; + ro.RefreshInterval = TimeSpan.FromMilliseconds(1); + }); + })); + } + + private static ConfigurationSetting CreateMicrosoftSchemaFlag(string id, bool enabled = true) + { + // A non-null telemetry block forces the adapter to emit under the Microsoft schema, + // which is the schema affected by the index offset strategy. + return ConfigurationModelFactory.ConfigurationSetting( + key: FeatureManagementConstants.FeatureFlagMarker + id, + value: $@" + {{ + ""id"": ""{id}"", + ""enabled"": {enabled.ToString().ToLowerInvariant()}, + ""telemetry"": {{ + ""enabled"": true + }} + }} + ", + contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", + eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); + } + Response GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken) { return Response.FromValue(FirstKeyValue, new MockResponse(200)); From 8c2adb2d3d6f18afad7aa381aad86e1f86412733 Mon Sep 17 00:00:00 2001 From: "Lingling Ye (from Dev Box)" Date: Wed, 27 May 2026 14:17:20 +0800 Subject: [PATCH 2/2] remove new APIs related changes --- .../AzureAppConfigurationOptions.cs | 112 ----------- .../FeatureManagement/FeatureFlagOptions2.cs | 130 ------------- .../FeatureFlagRefreshOptions2.cs | 30 --- .../Unit/FeatureManagementTests.cs | 183 ------------------ 4 files changed, 455 deletions(-) delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions2.cs delete mode 100644 src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagRefreshOptions2.cs diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs index 4c6dafc45..1d443625e 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs @@ -438,118 +438,6 @@ public AzureAppConfigurationOptions Connect(IEnumerable endpoints, TokenCre return this; } - /// - /// Connect the provider to the Azure App Configuration service via a connection string. This - /// is the entry point for the new feature flag experience; see . - /// Equivalent in connection behavior to . - /// - /// Used to authenticate with Azure App Configuration. - public AzureAppConfigurationOptions ConfigureConnection(string connectionString) - => Connect(connectionString); - - /// - /// Connect the provider to an Azure App Configuration store and its replicas via a list of - /// connection strings. This is the entry point for the new feature flag experience; see - /// . Equivalent in connection behavior to - /// . - /// - /// Used to authenticate with Azure App Configuration. - public AzureAppConfigurationOptions ConfigureConnection(IEnumerable connectionStrings) - => Connect(connectionStrings); - - /// - /// Connect the provider to Azure App Configuration using endpoint and token credentials. This - /// is the entry point for the new feature flag experience; see . - /// Equivalent in connection behavior to . - /// - /// The endpoint of the Azure App Configuration to connect to. - /// Token credentials to use to connect. - public AzureAppConfigurationOptions ConfigureConnection(Uri endpoint, TokenCredential credential) - => Connect(endpoint, credential); - - /// - /// Connect the provider to an Azure App Configuration store and its replicas using a list of - /// endpoints and a token credential. This is the entry point for the new feature flag - /// experience; see . Equivalent in connection behavior to - /// . - /// - /// The list of endpoints to connect to. - /// Token credential to use to connect. - public AzureAppConfigurationOptions ConfigureConnection(IEnumerable endpoints, TokenCredential credential) - => Connect(endpoints, credential); - - /// - /// Configures the new Azure App Configuration feature flag experience. Unlike - /// , callers must opt in explicitly by setting - /// to true, and refresh must be enabled - /// separately via . - /// - /// A callback used to configure feature flag options. - public AzureAppConfigurationOptions ConfigureFeatureFlags(Action configure) - { - if (configure == null) - { - throw new ArgumentNullException(nameof(configure)); - } - - var options = new FeatureFlagOptions2(); - configure(options); - - // Opt-in: if the caller did not explicitly enable feature flags this call is a no-op. - if (!options.Enabled) - { - return this; - } - - if (options.FeatureFlagSelectors.Count != 0 && options.Label != null) - { - throw new InvalidOperationException( - $"Please select feature flags by either the {nameof(FeatureFlagOptions2.Select)} method or by setting the {nameof(FeatureFlagOptions2.Label)} property, not both."); - } - - // Default selector when none is supplied: load all flags under the configured label. - if (options.FeatureFlagSelectors.Count == 0) - { - options.FeatureFlagSelectors.Add(new KeyValueSelector - { - KeyFilter = FeatureManagementConstants.FeatureFlagMarker + "*", - LabelFilter = string.IsNullOrWhiteSpace(options.Label) ? LabelFilter.Null : options.Label, - IsFeatureFlagSelector = true - }); - } - - // Refresh is opt-in on the new API. If refresh was not configured, or was configured - // with Enabled = false, no watcher is registered. - FeatureFlagRefreshOptions2 refresh = options.Refresh; - bool refreshEnabled = refresh != null && refresh.Enabled; - - if (refreshEnabled && refresh.RefreshInterval < RefreshConstants.MinimumFeatureFlagRefreshInterval) - { - throw new ArgumentOutOfRangeException( - nameof(FeatureFlagRefreshOptions2.RefreshInterval), - refresh.RefreshInterval.TotalMilliseconds, - string.Format(ErrorMessages.RefreshIntervalTooShort, RefreshConstants.MinimumFeatureFlagRefreshInterval.TotalMilliseconds)); - } - - foreach (KeyValueSelector featureFlagSelector in options.FeatureFlagSelectors) - { - _selectors.AppendUnique(featureFlagSelector); - - if (refreshEnabled) - { - _ffWatchers.AppendUnique(new KeyValueWatcher - { - Key = featureFlagSelector.KeyFilter, - Label = featureFlagSelector.LabelFilter, - Tags = featureFlagSelector.TagFilters, - RefreshInterval = refresh.RefreshInterval - }); - } - } - - 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/FeatureManagement/FeatureFlagOptions2.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions2.cs deleted file mode 100644 index 5f413b06e..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions2.cs +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; -using Microsoft.Extensions.Configuration.AzureAppConfiguration.Models; -using System; -using System.Collections.Generic; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement -{ - /// - /// Options used to configure feature flag loading via - /// , the new feature flag - /// experience offered by Azure App Configuration. - /// - /// - /// Draft name. Will be renamed prior to GA of the new feature flag experience. - /// In contrast to , this options type is fully opt-in: - /// must be set to true for any feature flags to be loaded, and - /// refresh must be enabled separately via . - /// - public class FeatureFlagOptions2 - { - /// - /// Whether feature flag loading is enabled for this provider. Defaults to false; - /// the new API requires the caller to opt in to feature flag loading explicitly. - /// - public bool Enabled { get; set; } - - /// - /// The label that feature flags will be selected from when no explicit - /// calls are made. Mutually - /// exclusive with . - /// - public string Label { get; set; } - - /// - /// A collection of selectors describing which feature flags to load. - /// - internal List FeatureFlagSelectors { get; } = new List(); - - /// - /// Refresh options configured via . null if refresh - /// has not been configured. - /// - internal FeatureFlagRefreshOptions2 Refresh { get; private set; } - - /// - /// Specifies the feature flags to include in the configuration provider. - /// can be called multiple times - /// to include multiple sets of feature flags. - /// - /// - /// The filter to apply to feature flag names. An asterisk (*) may be added to the end to - /// match by prefix. The characters asterisk (*), comma (,), and backslash (\) are - /// reserved and must be escaped with a backslash (\). - /// - /// - /// The label filter to apply. Defaults to the null label. The characters asterisk (*) and - /// comma (,) are not supported. - /// - /// - /// Optional tag filters of the form "tagName=tagValue". Up to five tag filters may be - /// supplied. - /// - public FeatureFlagOptions2 Select(string featureFlagFilter = KeyFilter.Any, string labelFilter = LabelFilter.Null, IEnumerable tagFilters = null) - { - if (string.IsNullOrEmpty(featureFlagFilter)) - { - throw new ArgumentNullException(nameof(featureFlagFilter)); - } - - if (featureFlagFilter.EndsWith(@"\*")) - { - throw new ArgumentException(@"Feature flag filter should not end with '\*'.", nameof(featureFlagFilter)); - } - - if (string.IsNullOrWhiteSpace(labelFilter)) - { - labelFilter = LabelFilter.Null; - } - - if (labelFilter.Contains("*") || labelFilter.Contains(",")) - { - throw new ArgumentException("The characters '*' and ',' are not supported in label filters.", nameof(labelFilter)); - } - - if (tagFilters != null) - { - foreach (string tag in tagFilters) - { - if (string.IsNullOrEmpty(tag) || !tag.Contains("=") || tag.IndexOf('=') == 0) - { - throw new ArgumentException($"Tag filter '{tag}' does not follow the format \"tagName=tagValue\".", nameof(tagFilters)); - } - } - } - - string featureFlagPrefix = FeatureManagementConstants.FeatureFlagMarker + featureFlagFilter; - - FeatureFlagSelectors.AppendUnique(new KeyValueSelector - { - KeyFilter = featureFlagPrefix, - LabelFilter = labelFilter, - TagFilters = tagFilters, - IsFeatureFlagSelector = true - }); - - return this; - } - - /// - /// Configures refresh behavior for the feature flags loaded by this provider. - /// - /// A callback used to configure refresh options. - public FeatureFlagOptions2 ConfigureRefresh(Action configure) - { - if (configure == null) - { - throw new ArgumentNullException(nameof(configure)); - } - - var refreshOptions = new FeatureFlagRefreshOptions2(); - configure(refreshOptions); - Refresh = refreshOptions; - - return this; - } - } -} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagRefreshOptions2.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagRefreshOptions2.cs deleted file mode 100644 index 8e4939aae..000000000 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagRefreshOptions2.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -// -using System; - -namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement -{ - /// - /// Options used to configure refresh for feature flags loaded via - /// . - /// - /// - /// Draft name. Will be renamed prior to GA of the new feature flag experience. - /// - public class FeatureFlagRefreshOptions2 - { - /// - /// Whether feature flag refresh is enabled. Defaults to false; the new API requires - /// the caller to opt in to background refresh explicitly. - /// - public bool Enabled { get; set; } - - /// - /// The minimum time interval between consecutive refresh operations for feature flags. - /// Must be greater than or equal to . - /// Defaults to . - /// - public TimeSpan RefreshInterval { get; set; } = RefreshConstants.DefaultFeatureFlagRefreshInterval; - } -} diff --git a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs index d9fbc6456..515ba3ccb 100644 --- a/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs @@ -2511,189 +2511,6 @@ public void FeatureFlagIndexOffset_DuplicateFlagIds_LaterProviderWins() Assert.Equal("True", config[$"feature_management:feature_flags:{stride}:enabled"]); } - [Fact] - public void ConfigureFeatureFlags_NotEnabled_NoFlagsLoaded() - { - // The new API is fully opt-in: omitting fo.Enabled = true must not register any - // feature flag selectors or watchers, even if Select() was called. - var options = new AzureAppConfigurationOptions(); - int baseSelectorCount = options.Selectors.Count(); - - options.ConfigureFeatureFlags(fo => - { - // Enabled is intentionally left false. - fo.Select(); - fo.ConfigureRefresh(ro => - { - ro.Enabled = true; - ro.RefreshInterval = TimeSpan.FromMinutes(1); - }); - }); - - Assert.Equal(baseSelectorCount, options.Selectors.Count()); - Assert.Empty(options.FeatureFlagWatchers); - } - - [Fact] - public void ConfigureFeatureFlags_Enabled_LoadsFlagsUnderMicrosoftSchema() - { - var mockClient = new Mock(MockBehavior.Strict); - mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(new List - { - CreateMicrosoftSchemaFlag("NewFlagA"), - CreateMicrosoftSchemaFlag("NewFlagB") - })); - - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); - options.ConfigureFeatureFlags(fo => - { - fo.Enabled = true; - fo.Select(); - }); - }) - .Build(); - - Assert.Equal("NewFlagA", config["feature_management:feature_flags:0:id"]); - Assert.Equal("NewFlagB", config["feature_management:feature_flags:1:id"]); - } - - [Fact] - public void ConfigureFeatureFlags_ClassicProviderFirst_NewProviderApplyOffset() - { - // classic provider registered first via - // UseFeatureFlags, new provider registered second via ConfigureFeatureFlags. The - // new provider must pick up the FeatureFlagIndexStride offset automatically so its - // flags do not collide with the classic provider's. - var classicMock = new Mock(MockBehavior.Strict); - classicMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(new List - { - CreateMicrosoftSchemaFlag("ClassicFlagA"), - CreateMicrosoftSchemaFlag("ClassicFlagB") - })); - - var newMock = new Mock(MockBehavior.Strict); - newMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(new List - { - CreateMicrosoftSchemaFlag("NewFlagA"), - CreateMicrosoftSchemaFlag("NewFlagB") - })); - - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(classicMock.Object); - options.UseFeatureFlags(); - }) - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(newMock.Object); - options.ConfigureFeatureFlags(fo => - { - fo.Enabled = true; - fo.Select(); - }); - }) - .Build(); - - // Classic provider keeps the low indices. - Assert.Equal("ClassicFlagA", config["feature_management:feature_flags:0:id"]); - Assert.Equal("ClassicFlagB", config["feature_management:feature_flags:1:id"]); - - // New provider shifted past the stride. - int stride = FeatureManagementConstants.FeatureFlagIndexStride; - Assert.Equal("NewFlagA", config[$"feature_management:feature_flags:{stride}:id"]); - Assert.Equal("NewFlagB", config[$"feature_management:feature_flags:{stride + 1}:id"]); - } - - [Fact] - public void ConfigureFeatureFlags_DuplicateFlagId_NewProviderWins() - { - // The "register new provider after classic" guidance hinges on the new flag - // overriding the classic flag when ids collide. This locks in that behavior. - var classicMock = new Mock(MockBehavior.Strict); - classicMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(new List - { - CreateMicrosoftSchemaFlag("Beta", enabled: false) - })); - - var newMock = new Mock(MockBehavior.Strict); - newMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny(), It.IsAny())) - .Returns(new MockAsyncPageable(new List - { - CreateMicrosoftSchemaFlag("Beta", enabled: true) - })); - - var config = new ConfigurationBuilder() - .AddAzureAppConfiguration(options => - { - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(classicMock.Object); - options.UseFeatureFlags(); - }) - .AddAzureAppConfiguration(options => - { - options.ConfigureConnection("Endpoint=https://example.azconfig.io;Id=Foo;Secret=Zm9v"); - options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(newMock.Object); - options.ConfigureFeatureFlags(fo => - { - fo.Enabled = true; - fo.Select(); - }); - }) - .Build(); - - int stride = FeatureManagementConstants.FeatureFlagIndexStride; - - // Both occurrences are emitted at distinct indices so the Feature Management library's - // LastOrDefault deduplication picks the newer flag. - Assert.Equal("Beta", config["feature_management:feature_flags:0:id"]); - Assert.Equal("False", config["feature_management:feature_flags:0:enabled"]); - Assert.Equal("Beta", config[$"feature_management:feature_flags:{stride}:id"]); - Assert.Equal("True", config[$"feature_management:feature_flags:{stride}:enabled"]); - } - - [Fact] - public void ConfigureFeatureFlags_LabelAndSelectMutuallyExclusive_Throws() - { - // Mirror the existing UseFeatureFlags validation: callers cannot mix Label with Select. - var options = new AzureAppConfigurationOptions(); - - var ex = Assert.Throws(() => - options.ConfigureFeatureFlags(fo => - { - fo.Enabled = true; - fo.Label = "prod"; - fo.Select("MyApp*"); - })); - - Assert.Contains(nameof(FeatureFlagOptions2.Select), ex.Message); - Assert.Contains(nameof(FeatureFlagOptions2.Label), ex.Message); - } - - [Fact] - public void ConfigureFeatureFlags_RefreshIntervalTooShort_Throws() - { - // Refresh validation parity with UseFeatureFlags: a sub-minimum interval must throw. - var options = new AzureAppConfigurationOptions(); - - Assert.Throws(() => - options.ConfigureFeatureFlags(fo => - { - fo.Enabled = true; - fo.ConfigureRefresh(ro => - { - ro.Enabled = true; - ro.RefreshInterval = TimeSpan.FromMilliseconds(1); - }); - })); - } - private static ConfigurationSetting CreateMicrosoftSchemaFlag(string id, bool enabled = true) { // A non-null telemetry block forces the adapter to emit under the Microsoft schema,