Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.Extensions.DependencyInjection.Extensions;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Microsoft.Extensions.Configuration
{
Expand Down Expand Up @@ -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<AzureAppConfigurationSource>().Count();

configurationBuilder.Add(new AzureAppConfigurationSource(action, optional, priorAppConfigSourceCount));
}

return configurationBuilder;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,22 @@ internal IEnumerable<IKeyValueAdapter> Adapters
/// </summary>
internal FeatureFlagTracing FeatureFlagTracing { get; set; } = new FeatureFlagTracing();

/// <summary>
/// The starting index used when emitting feature flags into the configuration system under
/// the Microsoft schema (feature_management:feature_flags:&lt;index&gt;).
///
/// 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
/// <see cref="FeatureManagement.FeatureManagementConstants.FeatureFlagIndexStride"/>, the
/// third uses 2 * <see cref="FeatureManagement.FeatureManagementConstants.FeatureFlagIndexStride"/>,
/// and so on.
/// </summary>
internal int FeatureFlagIndexOffset { get; set; } = 0;

/// <summary>
/// Options used to configure provider startup.
/// </summary>
Expand All @@ -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 <see cref="Select"/> and <see cref="SelectSnapshot"/> are never called.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,12 +15,22 @@ internal class AzureAppConfigurationSource : IConfigurationSource
private readonly bool _optional;
private readonly Func<AzureAppConfigurationOptions> _optionsProvider;

public AzureAppConfigurationSource(Action<AzureAppConfigurationOptions> optionsInitializer, bool optional = false)
public AzureAppConfigurationSource(Action<AzureAppConfigurationOptions> 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;
}
Comment on lines +29 to +32

return options;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
linglingye001 marked this conversation as resolved.
public const int FeatureFlagIndexStride = 10000;

// Feature flag properties
public const string Id = "id";
public const string Enabled = "enabled";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -42,7 +45,7 @@ public Task<IEnumerable<KeyValuePair<string, string>>> ProcessKeyValue(Configura
featureFlag.Allocation != null ||
featureFlag.Telemetry != null)
{
keyValues = ProcessMicrosoftSchemaFeatureFlag(featureFlag, setting, endpoint);
keyValues = ProcessMicrosoftSchemaFeatureFlag(featureFlag, setting, endpoint, logger);
}
else
{
Expand Down Expand Up @@ -83,6 +86,7 @@ public void OnChangeDetected(ConfigurationSetting setting = null)
public void OnConfigUpdated()
{
_featureFlagIndex = 0;
_warnedAboutIndexStrideOverflow = false;

return;
}
Expand Down Expand Up @@ -140,7 +144,7 @@ private List<KeyValuePair<string, string>> ProcessDotnetSchemaFeatureFlag(Featur
return keyValues;
}

private List<KeyValuePair<string, string>> ProcessMicrosoftSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint)
private List<KeyValuePair<string, string>> ProcessMicrosoftSchemaFeatureFlag(FeatureFlag featureFlag, ConfigurationSetting setting, Uri endpoint, Logger logger)
{
var keyValues = new List<KeyValuePair<string, string>>();

Expand All @@ -149,10 +153,25 @@ private List<KeyValuePair<string, string>> 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.
Comment on lines +162 to +163
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<string, string>($"{featureFlagPath}:{FeatureManagementConstants.Id}", featureFlag.Id));

keyValues.Add(new KeyValuePair<string, string>($"{featureFlagPath}:{FeatureManagementConstants.Enabled}", featureFlag.Enabled.ToString()));
Expand Down
170 changes: 170 additions & 0 deletions tests/Tests.AzureAppConfiguration/Unit/FeatureManagementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2360,6 +2360,176 @@ 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<ConfigurationClient>(MockBehavior.Strict);

mockClient.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.Returns(new MockAsyncPageable(new List<ConfigurationSetting> { 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<ConfigurationClient>(MockBehavior.Strict);
classicMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.Returns(new MockAsyncPageable(new List<ConfigurationSetting>
{
CreateMicrosoftSchemaFlag("ClassicFlagA"),
CreateMicrosoftSchemaFlag("ClassicFlagB")
}));

var newMock = new Mock<ConfigurationClient>(MockBehavior.Strict);
newMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.Returns(new MockAsyncPageable(new List<ConfigurationSetting>
{
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<ConfigurationClient>(MockBehavior.Strict);
mock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.Returns(new MockAsyncPageable(new List<ConfigurationSetting> { 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.
Comment on lines +2473 to +2477
var classicMock = new Mock<ConfigurationClient>(MockBehavior.Strict);
classicMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.Returns(new MockAsyncPageable(new List<ConfigurationSetting>
{
CreateMicrosoftSchemaFlag("Beta", enabled: false)
}));

var newMock = new Mock<ConfigurationClient>(MockBehavior.Strict);
newMock.Setup(c => c.GetConfigurationSettingsAsync(It.IsAny<SettingSelector>(), It.IsAny<CancellationToken>()))
.Returns(new MockAsyncPageable(new List<ConfigurationSetting>
{
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"]);
}

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<ConfigurationSetting> GetIfChanged(ConfigurationSetting setting, bool onlyIfChanged, CancellationToken cancellationToken)
{
return Response.FromValue(FirstKeyValue, new MockResponse(200));
Expand Down
Loading