Skip to content

Commit de2a186

Browse files
Merge pull request #554 from microsoft/main
Merge main to release/v4
2 parents 4d6de08 + b7ee3a5 commit de2a186

File tree

7 files changed

+202
-34
lines changed

7 files changed

+202
-34
lines changed

src/Microsoft.FeatureManagement.AspNetCore/Microsoft.FeatureManagement.AspNetCore.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<!-- Official Version -->
66
<PropertyGroup>
77
<MajorVersion>4</MajorVersion>
8-
<MinorVersion>2</MinorVersion>
9-
<PatchVersion>1</PatchVersion>
8+
<MinorVersion>3</MinorVersion>
9+
<PatchVersion>0</PatchVersion>
1010
</PropertyGroup>
1111

1212
<Import Project="..\..\build\Versioning.props" />

src/Microsoft.FeatureManagement.Telemetry.ApplicationInsights/Microsoft.FeatureManagement.Telemetry.ApplicationInsights.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
<!-- Official Version -->
55
<PropertyGroup>
66
<MajorVersion>4</MajorVersion>
7-
<MinorVersion>2</MinorVersion>
8-
<PatchVersion>1</PatchVersion>
7+
<MinorVersion>3</MinorVersion>
8+
<PatchVersion>0</PatchVersion>
99
</PropertyGroup>
1010

1111
<Import Project="..\..\build\Versioning.props" />

src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP
2323
// IFeatureDefinitionProviderCacheable interface is only used to mark this provider as cacheable. This allows our test suite's
2424
// provider to be marked for caching as well.
2525
private readonly IConfiguration _configuration;
26+
private readonly ConfigurationFeatureDefinitionProviderOptions _options;
2627
private IEnumerable<IConfigurationSection> _dotnetFeatureDefinitionSections;
2728
private IEnumerable<IConfigurationSection> _microsoftFeatureDefinitionSections;
2829
private readonly ConcurrentDictionary<string, Task<FeatureDefinition>> _definitions;
@@ -37,9 +38,13 @@ public sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionP
3738
/// Creates a configuration feature definition provider.
3839
/// </summary>
3940
/// <param name="configuration">The configuration of feature definitions.</param>
40-
public ConfigurationFeatureDefinitionProvider(IConfiguration configuration)
41+
/// <param name="options">The options for the configuration feature definition provider.</param>
42+
public ConfigurationFeatureDefinitionProvider(
43+
IConfiguration configuration,
44+
ConfigurationFeatureDefinitionProviderOptions options = null)
4145
{
4246
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
47+
_options = options ?? new ConfigurationFeatureDefinitionProviderOptions();
4348
_definitions = new ConcurrentDictionary<string, Task<FeatureDefinition>>();
4449

4550
_changeSubscription = ChangeToken.OnChange(
@@ -229,6 +234,13 @@ private IEnumerable<IConfigurationSection> GetDotnetFeatureDefinitionSections()
229234

230235
private IEnumerable<IConfigurationSection> GetMicrosoftFeatureDefinitionSections()
231236
{
237+
if (!_options.CustomConfigurationMergingEnabled)
238+
{
239+
return _configuration.GetSection(MicrosoftFeatureManagementFields.FeatureManagementSectionName)
240+
.GetSection(MicrosoftFeatureManagementFields.FeatureFlagsSectionName)
241+
.GetChildren();
242+
}
243+
232244
var featureDefinitionSections = new List<IConfigurationSection>();
233245

234246
FindFeatureFlags(_configuration, featureDefinitionSections);
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
//
4+
namespace Microsoft.FeatureManagement
5+
{
6+
/// <summary>
7+
/// Options that control the behavior of the <see cref="ConfigurationFeatureDefinitionProvider"/>.
8+
/// </summary>
9+
public class ConfigurationFeatureDefinitionProviderOptions
10+
{
11+
/// <summary>
12+
/// Controls whether to enable the custom configuration merging logic for Microsoft schema feature flags or fall back to .NET's native configuration merging behavior.
13+
/// </summary>
14+
/// <remarks>
15+
/// This option only affects Microsoft schema feature flags (e.g. feature_management:feature_flags arrays). .NET schema feature flags are not affected by this setting.
16+
///
17+
/// The <see cref="ConfigurationFeatureDefinitionProvider"/> uses custom configuration merging logic for Microsoft schema feature flags to ensure that
18+
/// feature flags with the same ID from different configuration sources are merged correctly based on their logical identity rather than array position.
19+
/// By default, the provider bypasses .NET's native array merging behavior which merges arrays by index position and can lead to unexpected results when feature flags are defined across multiple configuration sources.
20+
///
21+
/// Consider the following configuration sources:
22+
/// Configuration Source 1:
23+
/// {
24+
/// "feature_management": {
25+
/// "feature_flags": [
26+
/// {
27+
/// "id": "feature1",
28+
/// "enabled": true
29+
/// },
30+
/// {
31+
/// "id": "feature2",
32+
/// "enabled": false
33+
/// }
34+
/// ]
35+
/// }
36+
/// }
37+
///
38+
/// Configuration Source 2:
39+
/// {
40+
/// "feature_management": {
41+
/// "feature_flags": [
42+
/// {
43+
/// "id": "feature2",
44+
/// "enabled": true
45+
/// }
46+
/// ]
47+
/// }
48+
/// }
49+
///
50+
/// With custom merging:
51+
/// - feature1: enabled = true
52+
/// - feature2: enabled = true (last declaration wins)
53+
///
54+
/// With native .NET merging:
55+
/// - feature1 would be overwritten by feature2 from source 2 (index-based merging, e.g. feature_flags:0:id)
56+
/// - feature2: enabled = false (from source 1, index 1)
57+
/// </remarks>
58+
public bool CustomConfigurationMergingEnabled { get; set; }
59+
}
60+
}

src/Microsoft.FeatureManagement/Microsoft.FeatureManagement.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
<!-- Official Version -->
66
<PropertyGroup>
77
<MajorVersion>4</MajorVersion>
8-
<MinorVersion>2</MinorVersion>
9-
<PatchVersion>1</PatchVersion>
8+
<MinorVersion>3</MinorVersion>
9+
<PatchVersion>0</PatchVersion>
1010
</PropertyGroup>
1111

1212
<Import Project="..\..\build\Versioning.props" />

src/Microsoft.FeatureManagement/ServiceCollectionExtensions.cs

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,18 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec
3636

3737
AddCommonFeatureManagementServices(services);
3838

39-
services.AddSingleton(sp => new FeatureManager(
40-
sp.GetRequiredService<IFeatureDefinitionProvider>(),
41-
sp.GetRequiredService<IOptions<FeatureManagementOptions>>().Value)
42-
{
43-
FeatureFilters = sp.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>(),
44-
SessionManagers = sp.GetRequiredService<IEnumerable<ISessionManager>>(),
45-
Cache = sp.GetRequiredService<IMemoryCache>(),
46-
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<FeatureManager>(),
47-
TargetingContextAccessor = sp.GetService<ITargetingContextAccessor>(),
48-
AssignerOptions = sp.GetRequiredService<IOptions<TargetingEvaluationOptions>>().Value
49-
});
39+
services.AddSingleton(sp =>
40+
new FeatureManager(
41+
sp.GetRequiredService<IFeatureDefinitionProvider>(),
42+
sp.GetRequiredService<IOptions<FeatureManagementOptions>>().Value)
43+
{
44+
FeatureFilters = sp.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>(),
45+
SessionManagers = sp.GetRequiredService<IEnumerable<ISessionManager>>(),
46+
Cache = sp.GetRequiredService<IMemoryCache>(),
47+
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<FeatureManager>(),
48+
TargetingContextAccessor = sp.GetService<ITargetingContextAccessor>(),
49+
AssignerOptions = sp.GetRequiredService<IOptions<TargetingEvaluationOptions>>().Value
50+
});
5051

5152
services.TryAddSingleton<IFeatureManager>(sp => sp.GetRequiredService<FeatureManager>());
5253

@@ -70,7 +71,9 @@ public static IFeatureManagementBuilder AddFeatureManagement(this IServiceCollec
7071
}
7172

7273
services.AddSingleton<IFeatureDefinitionProvider>(sp =>
73-
new ConfigurationFeatureDefinitionProvider(configuration)
74+
new ConfigurationFeatureDefinitionProvider(
75+
configuration,
76+
sp.GetRequiredService<IOptions<ConfigurationFeatureDefinitionProviderOptions>>().Value)
7477
{
7578
RootConfigurationFallbackEnabled = true,
7679
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<ConfigurationFeatureDefinitionProvider>()
@@ -96,17 +99,18 @@ public static IFeatureManagementBuilder AddScopedFeatureManagement(this IService
9699

97100
AddCommonFeatureManagementServices(services);
98101

99-
services.AddScoped(sp => new FeatureManager(
100-
sp.GetRequiredService<IFeatureDefinitionProvider>(),
101-
sp.GetRequiredService<IOptions<FeatureManagementOptions>>().Value)
102-
{
103-
FeatureFilters = sp.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>(),
104-
SessionManagers = sp.GetRequiredService<IEnumerable<ISessionManager>>(),
105-
Cache = sp.GetRequiredService<IMemoryCache>(),
106-
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<FeatureManager>(),
107-
TargetingContextAccessor = sp.GetService<ITargetingContextAccessor>(),
108-
AssignerOptions = sp.GetRequiredService<IOptions<TargetingEvaluationOptions>>().Value
109-
});
102+
services.AddScoped(sp =>
103+
new FeatureManager(
104+
sp.GetRequiredService<IFeatureDefinitionProvider>(),
105+
sp.GetRequiredService<IOptions<FeatureManagementOptions>>().Value)
106+
{
107+
FeatureFilters = sp.GetRequiredService<IEnumerable<IFeatureFilterMetadata>>(),
108+
SessionManagers = sp.GetRequiredService<IEnumerable<ISessionManager>>(),
109+
Cache = sp.GetRequiredService<IMemoryCache>(),
110+
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<FeatureManager>(),
111+
TargetingContextAccessor = sp.GetService<ITargetingContextAccessor>(),
112+
AssignerOptions = sp.GetRequiredService<IOptions<TargetingEvaluationOptions>>().Value
113+
});
110114

111115
services.TryAddScoped<IFeatureManager>(sp => sp.GetRequiredService<FeatureManager>());
112116

@@ -130,7 +134,9 @@ public static IFeatureManagementBuilder AddScopedFeatureManagement(this IService
130134
}
131135

132136
services.AddSingleton<IFeatureDefinitionProvider>(sp =>
133-
new ConfigurationFeatureDefinitionProvider(configuration)
137+
new ConfigurationFeatureDefinitionProvider(
138+
configuration,
139+
sp.GetRequiredService<IOptions<ConfigurationFeatureDefinitionProviderOptions>>().Value)
134140
{
135141
RootConfigurationFallbackEnabled = true,
136142
Logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger<ConfigurationFeatureDefinitionProvider>()
@@ -145,7 +151,11 @@ private static void AddCommonFeatureManagementServices(IServiceCollection servic
145151

146152
services.AddMemoryCache();
147153

148-
services.TryAddSingleton<IFeatureDefinitionProvider, ConfigurationFeatureDefinitionProvider>();
154+
services.TryAddSingleton<IFeatureDefinitionProvider>(sp =>
155+
new ConfigurationFeatureDefinitionProvider(
156+
sp.GetRequiredService<IConfiguration>(),
157+
sp.GetRequiredService<IOptions<ConfigurationFeatureDefinitionProviderOptions>>().Value)
158+
);
149159

150160
services.AddScoped<FeatureManagerSnapshot>();
151161

tests/Tests.FeatureManagement/FeatureManagementTest.cs

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,11 @@ public async Task LastFeatureFlagWins()
398398
[Fact]
399399
public async Task MergesFeatureFlagsFromDifferentConfigurationSources()
400400
{
401+
var mergeOptions = new ConfigurationFeatureDefinitionProviderOptions()
402+
{
403+
CustomConfigurationMergingEnabled = true
404+
};
405+
401406
/*
402407
* appsettings1.json
403408
* Feature1: true
@@ -425,18 +430,99 @@ public async Task MergesFeatureFlagsFromDifferentConfigurationSources()
425430
.AddJsonFile("appsettings3.json")
426431
.Build();
427432

428-
var featureManager1 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration1));
433+
var featureManager1 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration1, mergeOptions));
429434
Assert.True(await featureManager1.IsEnabledAsync("FeatureA"));
430435
Assert.True(await featureManager1.IsEnabledAsync("FeatureB"));
431436
Assert.True(await featureManager1.IsEnabledAsync("Feature1"));
432437
Assert.False(await featureManager1.IsEnabledAsync("Feature2")); // appsettings2 should override appsettings1
433438

434-
var featureManager2 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration2));
439+
var featureManager2 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration2, mergeOptions));
435440
Assert.True(await featureManager2.IsEnabledAsync("FeatureA"));
436441
Assert.True(await featureManager2.IsEnabledAsync("FeatureB"));
437442
Assert.True(await featureManager2.IsEnabledAsync("FeatureC"));
438443
Assert.False(await featureManager2.IsEnabledAsync("Feature1")); // appsettings3 should override previous settings
439444
Assert.False(await featureManager2.IsEnabledAsync("Feature2")); // appsettings3 should override previous settings
445+
446+
//
447+
// default behavior
448+
var featureManager3 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration1));
449+
Assert.False(await featureManager3.IsEnabledAsync("FeatureA")); // it will be overridden by FeatureB
450+
Assert.True(await featureManager3.IsEnabledAsync("FeatureB"));
451+
Assert.True(await featureManager3.IsEnabledAsync("Feature1"));
452+
Assert.False(await featureManager3.IsEnabledAsync("Feature2")); // appsettings2 should override appsettings1
453+
454+
IConfiguration configuration3 = new ConfigurationBuilder()
455+
.AddJsonFile("appsettings1.json")
456+
.AddInMemoryCollection(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
457+
{
458+
["feature_management:feature_flags:0:enabled"] = bool.FalseString,
459+
["feature_management:feature_flags:1:enabled"] = bool.FalseString,
460+
})
461+
.Build();
462+
var featureManager4 = new FeatureManager(new ConfigurationFeatureDefinitionProvider(configuration3));
463+
Assert.False(await featureManager4.IsEnabledAsync("Feature1"));
464+
Assert.False(await featureManager4.IsEnabledAsync("Feature2"));
465+
Assert.True(await featureManager4.IsEnabledAsync("FeatureA"));
466+
467+
//
468+
// DI usage
469+
var services1 = new ServiceCollection();
470+
services1
471+
.AddSingleton(configuration2)
472+
.AddFeatureManagement();
473+
services1.Configure<ConfigurationFeatureDefinitionProviderOptions>(o =>
474+
{
475+
o.CustomConfigurationMergingEnabled = true;
476+
});
477+
ServiceProvider serviceProvider1 = services1.BuildServiceProvider();
478+
IFeatureManager featureManager5 = serviceProvider1.GetRequiredService<IFeatureManager>();
479+
480+
Assert.True(await featureManager5.IsEnabledAsync("FeatureA"));
481+
Assert.True(await featureManager5.IsEnabledAsync("FeatureB"));
482+
Assert.True(await featureManager5.IsEnabledAsync("FeatureC"));
483+
Assert.False(await featureManager5.IsEnabledAsync("Feature1"));
484+
Assert.False(await featureManager5.IsEnabledAsync("Feature2"));
485+
486+
var services2 = new ServiceCollection();
487+
services2
488+
.AddSingleton(configuration2)
489+
.AddFeatureManagement();
490+
ServiceProvider serviceProvider2 = services2.BuildServiceProvider();
491+
IFeatureManager featureManager6 = serviceProvider2.GetRequiredService<IFeatureManager>();
492+
493+
Assert.False(await featureManager6.IsEnabledAsync("FeatureA"));
494+
Assert.False(await featureManager6.IsEnabledAsync("FeatureB"));
495+
Assert.True(await featureManager6.IsEnabledAsync("FeatureC"));
496+
Assert.False(await featureManager6.IsEnabledAsync("Feature1"));
497+
Assert.False(await featureManager6.IsEnabledAsync("Feature2"));
498+
499+
var services3 = new ServiceCollection();
500+
services3
501+
.AddFeatureManagement(configuration2);
502+
services3.Configure<ConfigurationFeatureDefinitionProviderOptions>(o =>
503+
{
504+
o.CustomConfigurationMergingEnabled = true;
505+
});
506+
ServiceProvider serviceProvider3 = services3.BuildServiceProvider();
507+
IFeatureManager featureManager7 = serviceProvider3.GetRequiredService<IFeatureManager>();
508+
509+
Assert.True(await featureManager7.IsEnabledAsync("FeatureA"));
510+
Assert.True(await featureManager7.IsEnabledAsync("FeatureB"));
511+
Assert.True(await featureManager7.IsEnabledAsync("FeatureC"));
512+
Assert.False(await featureManager7.IsEnabledAsync("Feature1"));
513+
Assert.False(await featureManager7.IsEnabledAsync("Feature2"));
514+
515+
var services4 = new ServiceCollection();
516+
services4
517+
.AddFeatureManagement(configuration2);
518+
ServiceProvider serviceProvider4 = services4.BuildServiceProvider();
519+
IFeatureManager featureManager8 = serviceProvider4.GetRequiredService<IFeatureManager>();
520+
521+
Assert.False(await featureManager8.IsEnabledAsync("FeatureA"));
522+
Assert.False(await featureManager8.IsEnabledAsync("FeatureB"));
523+
Assert.True(await featureManager8.IsEnabledAsync("FeatureC"));
524+
Assert.False(await featureManager8.IsEnabledAsync("Feature1"));
525+
Assert.False(await featureManager8.IsEnabledAsync("Feature2"));
440526
}
441527
}
442528

0 commit comments

Comments
 (0)