From 40474ac13479a7b4a69f18afefb7dfc2d4a0c086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Mon, 24 Aug 2026 23:37:05 +0300 Subject: [PATCH 1/8] feat(variants): allow in-memory objects --- src/Microsoft.FeatureManagement/FeatureManager.cs | 5 +++-- src/Microsoft.FeatureManagement/Variant.cs | 6 ++++++ src/Microsoft.FeatureManagement/VariantDefinition.cs | 8 ++++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index f7bca84a..a6ebed3c 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -847,10 +847,11 @@ private Variant GetVariantFromVariantDefinition(VariantDefinition variantDefinit variantConfiguration = variantDefinition.ConfigurationValue; } - return new Variant() + return new Variant { Name = variantDefinition.Name, - Configuration = variantConfiguration + Configuration = variantConfiguration, + ConfigurationObject = variantDefinition.ConfigurationObject, }; } } diff --git a/src/Microsoft.FeatureManagement/Variant.cs b/src/Microsoft.FeatureManagement/Variant.cs index f69a47ce..012b5f3b 100644 --- a/src/Microsoft.FeatureManagement/Variant.cs +++ b/src/Microsoft.FeatureManagement/Variant.cs @@ -19,5 +19,11 @@ public class Variant /// The configuration of the variant. /// public IConfigurationSection Configuration { get; set; } + + /// + /// The configuration of the variant. + /// When set, variants should prefer this over . + /// + public object ConfigurationObject { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/VariantDefinition.cs b/src/Microsoft.FeatureManagement/VariantDefinition.cs index 138d2fc5..0b8b0a3e 100644 --- a/src/Microsoft.FeatureManagement/VariantDefinition.cs +++ b/src/Microsoft.FeatureManagement/VariantDefinition.cs @@ -21,6 +21,14 @@ public class VariantDefinition /// public IConfigurationSection ConfigurationValue { get; set; } + /// + /// A parameter object that can be used as an alternative to . + /// Custom implementations can populate this property directly + /// instead of constructing an instance. + /// When set, variants should prefer this over . + /// + public object ConfigurationObject { get; set; } + /// /// Overrides the state of the feature if this variant has been assigned. /// From 90fe16e4af3ff3fe91659190b9aa7469733d35dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D0=BD=D0=B8=D0=BD=20=D0=A1=D1=82=D0=B5=D0=BF?= =?UTF-8?q?=D0=B0=D0=BD=20=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Mon, 24 Aug 2026 23:42:01 +0300 Subject: [PATCH 2/8] docs(variants): allow in-memory objects --- src/Microsoft.FeatureManagement/VariantDefinition.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.FeatureManagement/VariantDefinition.cs b/src/Microsoft.FeatureManagement/VariantDefinition.cs index 0b8b0a3e..b26fb13e 100644 --- a/src/Microsoft.FeatureManagement/VariantDefinition.cs +++ b/src/Microsoft.FeatureManagement/VariantDefinition.cs @@ -22,7 +22,7 @@ public class VariantDefinition public IConfigurationSection ConfigurationValue { get; set; } /// - /// A parameter object that can be used as an alternative to . + /// A configuration object that can be used as an alternative to . /// Custom implementations can populate this property directly /// instead of constructing an instance. /// When set, variants should prefer this over . From ee4128495951dc56ae8599519f6d30df889062fb Mon Sep 17 00:00:00 2001 From: Stepami Date: Fri, 11 Sep 2026 20:10:41 +0300 Subject: [PATCH 3/8] feat(variants): populate ConfigurationObject from configuration section --- .../ConfigurationFeatureDefinitionProvider.cs | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 72ed0757..53906497 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using System.Threading; @@ -119,7 +120,7 @@ public Task GetFeatureDefinitionAsync(string featureName) /// An enumerator which provides asynchronous iteration over feature definitions. // // The async key word is necessary for creating IAsyncEnumerable. - // The need to disable this warning occurs when implementing async stream synchronously. + // The need to disable this warning occurs when implementing async stream synchronously. #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously public async IAsyncEnumerable GetAllFeatureDefinitionsAsync() #pragma warning restore CS1998 @@ -300,9 +301,9 @@ private void FindFeatureFlags(IConfiguration configuration, List(configurationSection.Key, rawStatusOverride, MicrosoftFeatureManagementFields.VariantDefinitionStatusOverride); } + var configurationValue = section.GetSection( + MicrosoftFeatureManagementFields.VariantDefinitionConfigurationValue); + var variant = new VariantDefinition() { Name = section[MicrosoftFeatureManagementFields.Name], - ConfigurationValue = section.GetSection(MicrosoftFeatureManagementFields.VariantDefinitionConfigurationValue), + ConfigurationValue = configurationValue, + ConfigurationObject = configurationValue.Exists() + ? CreateConfigurationObject(configurationValue) + : null, StatusOverride = statusOverride }; @@ -602,6 +609,25 @@ private FeatureDefinition ParseMicrosoftSchemaFeatureDefinition(IConfigurationSe }; } + private static IReadOnlyDictionary CreateConfigurationObject(IConfigurationSection section) + { + var values = section + .AsEnumerable(makePathsRelative: true) + .Where(x => x.Value != null) + .ToDictionary( + entry => entry.Key, + entry => entry.Value, + StringComparer.OrdinalIgnoreCase); + + // Relative enumeration excludes the section's own value. Preserve it under the empty key. + if (section.Value != null) + { + values.Add(string.Empty, section.Value); + } + + return new ReadOnlyDictionary(values); + } + private static T ParseEnum(string feature, string rawValue, string fieldKeyword) where T : struct, Enum { From 2070378fe894d862efcb24079dca3fdbf59113dd Mon Sep 17 00:00:00 2001 From: Stepami Date: Fri, 11 Sep 2026 20:38:45 +0300 Subject: [PATCH 4/8] test(variants): populate ConfigurationObject from configuration section --- tests/Tests.FeatureManagement/FeatureManagementTest.cs | 9 +++++++++ tests/Tests.FeatureManagement/appsettings.json | 8 ++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index a2aaeed1..f6712cd1 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2086,6 +2086,15 @@ public async Task UsesVariants() // Test DefaultWhenEnabled and ConfigurationValue with inline IConfigurationSection variant = await featureManager.GetVariantAsync(Features.VariantFeatureDefaultEnabled, cancellationToken); + var configurationObject = (IReadOnlyDictionary)variant.ConfigurationObject; + Assert.True(configurationObject.Keys.ToHashSet().SetEquals(new[] + { + "Size", + "Color", + "Platform:Id", + "Platform:Screens:0", + "Platform:Screens:1" + })); Assert.Equal("Medium", variant.Name); Assert.Equal("450px", variant.Configuration["Size"]); diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index 018ef5c4..9b555e7e 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -295,7 +295,11 @@ "name": "Medium", "configuration_value": { "Size": "450px", - "Color": "Purple" + "Color": "Purple", + "Platform": { + "Id": "mobile", + "Screens": ["fullhd", "tablet"] + } } }, { @@ -569,4 +573,4 @@ } ] } -} +} From 88c41a66182b85056935cffad0222b14b0b4a921 Mon Sep 17 00:00:00 2001 From: Stepami Date: Fri, 11 Sep 2026 21:05:16 +0300 Subject: [PATCH 5/8] feat(variants): common consumption api --- .../VariantExtensions.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/Microsoft.FeatureManagement/VariantExtensions.cs diff --git a/src/Microsoft.FeatureManagement/VariantExtensions.cs b/src/Microsoft.FeatureManagement/VariantExtensions.cs new file mode 100644 index 00000000..61e2d76b --- /dev/null +++ b/src/Microsoft.FeatureManagement/VariantExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; + +namespace Microsoft.FeatureManagement +{ + /// + /// Extensions for . + /// + public static class VariantExtensions + { + /// + /// Gets the variant configuration as the requested type. + /// + /// The type of the configuration. + /// The variant to read. + /// + /// The supplied configuration object when assignable to ; + /// otherwise, the configuration bound to from . + /// Returns default when the variant or its configuration is absent. + /// + public static T GetConfiguration(this Variant variant) + { + if (variant == null) + { + return default; + } + + if (variant.ConfigurationObject is T typedConfigurationObject) + { + return typedConfigurationObject; + } + + return variant.Configuration != null + ? variant.Configuration.Get() + : default; + } + } +} From 2a758225f958b66b1b9723a29f8a9fa714642d6f Mon Sep 17 00:00:00 2001 From: Stepami Date: Fri, 11 Sep 2026 21:05:21 +0300 Subject: [PATCH 6/8] test(variants): common consumption api --- .../VariantExtensionsTest.cs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/Tests.FeatureManagement/VariantExtensionsTest.cs diff --git a/tests/Tests.FeatureManagement/VariantExtensionsTest.cs b/tests/Tests.FeatureManagement/VariantExtensionsTest.cs new file mode 100644 index 00000000..a47b5547 --- /dev/null +++ b/tests/Tests.FeatureManagement/VariantExtensionsTest.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Memory; +using Microsoft.FeatureManagement; +using System.Collections.Generic; +using Xunit; + +namespace Tests.FeatureManagement +{ + public class VariantExtensionsTest + { + [Fact] + public void GetConfigurationReturnsNullForNullVariant() + { + Variant variant = null; + + Assert.Null(variant.GetConfiguration()); + } + + [Fact] + public void GetConfigurationPrefersAssignableObject() + { + var supplied = new List { "supplied" }; + + var provider = new MemoryConfigurationProvider(new MemoryConfigurationSource()); + var configurationSection = new ConfigurationSection( + new ConfigurationRoot(new List { provider }), + "Param"); + provider.Set(configurationSection.Key, "42"); + var variant = new Variant + { + ConfigurationObject = supplied, + Configuration = configurationSection + }; + + Assert.Same(supplied, variant.GetConfiguration>()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void GetConfigurationFallsBackToSection(bool hasIncompatibleObject) + { + var provider = new MemoryConfigurationProvider(new MemoryConfigurationSource + { + InitialData = new Dictionary + { + ["Value:AccountId"] = "1", + ["Value:UserId"] = "2", + ["Value:Groups:0"] = "Chrome", + ["Value:Groups:1"] = "Edge", + } + }); + var configurationSection = new ConfigurationSection( + new ConfigurationRoot(new List { provider }), + "Value"); + var variant = new Variant + { + ConfigurationObject = hasIncompatibleObject + ? "some value" + : null, + Configuration = configurationSection + }; + + Assert.Equivalent(new AppContext + { + AccountId = "1", + UserId = "2", + Groups = new List { "Chrome", "Edge" } + }, variant.GetConfiguration()); + } + + [Fact] + public void GetConfigurationBindsScalar() + { + var provider = new MemoryConfigurationProvider(new MemoryConfigurationSource()); + var configurationSection = new ConfigurationSection( + new ConfigurationRoot(new List { provider }), + "Param"); + provider.Set(configurationSection.Key, "42"); + var variant = new Variant + { + Configuration = configurationSection + }; + + Assert.Equal(42, variant.GetConfiguration()); + } + + [Fact] + public void GetConfigurationReturnsNullWithoutConfiguration() + { + Assert.Null(new Variant().GetConfiguration()); + Assert.Null(new Variant().GetConfiguration()); + } + } +} From 9c76a25d422eb17acd75d591d9e7d5ed21b08aaa Mon Sep 17 00:00:00 2001 From: Stepami Date: Sat, 3 Oct 2026 16:49:22 +0300 Subject: [PATCH 7/8] revert(variants): populate ConfigurationObject from configuration section --- .../ConfigurationFeatureDefinitionProvider.cs | 28 +------------------ .../FeatureManagementTest.cs | 10 +------ .../Tests.FeatureManagement/appsettings.json | 6 +--- 3 files changed, 3 insertions(+), 41 deletions(-) diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index 33ba5721..f648759e 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; using System.Threading; @@ -552,16 +551,10 @@ private FeatureDefinition ParseMicrosoftSchemaFeatureDefinition(IConfigurationSe statusOverride = ParseEnum(configurationSection.Key, rawStatusOverride, MicrosoftFeatureManagementFields.VariantDefinitionStatusOverride); } - var configurationValue = section.GetSection( - MicrosoftFeatureManagementFields.VariantDefinitionConfigurationValue); - var variant = new VariantDefinition() { Name = section[MicrosoftFeatureManagementFields.Name], - ConfigurationValue = configurationValue, - ConfigurationObject = configurationValue.Exists() - ? CreateConfigurationObject(configurationValue) - : null, + ConfigurationValue = section.GetSection(MicrosoftFeatureManagementFields.VariantDefinitionConfigurationValue), StatusOverride = statusOverride }; @@ -606,25 +599,6 @@ private FeatureDefinition ParseMicrosoftSchemaFeatureDefinition(IConfigurationSe }; } - private static IReadOnlyDictionary CreateConfigurationObject(IConfigurationSection section) - { - var values = section - .AsEnumerable(makePathsRelative: true) - .Where(x => x.Value != null) - .ToDictionary( - entry => entry.Key, - entry => entry.Value, - StringComparer.OrdinalIgnoreCase); - - // Relative enumeration excludes the section's own value. Preserve it under the empty key. - if (section.Value != null) - { - values.Add(string.Empty, section.Value); - } - - return new ReadOnlyDictionary(values); - } - private static T ParseEnum(string feature, string rawValue, string fieldKeyword) where T : struct, Enum { diff --git a/tests/Tests.FeatureManagement/FeatureManagementTest.cs b/tests/Tests.FeatureManagement/FeatureManagementTest.cs index 446411fe..8a2f3f38 100644 --- a/tests/Tests.FeatureManagement/FeatureManagementTest.cs +++ b/tests/Tests.FeatureManagement/FeatureManagementTest.cs @@ -2524,17 +2524,9 @@ public async Task UsesVariants() // Test DefaultWhenEnabled and ConfigurationValue with inline IConfigurationSection variant = await featureManager.GetVariantAsync(Features.VariantFeatureDefaultEnabled, cancellationToken); - var configurationObject = (IReadOnlyDictionary)variant.ConfigurationObject; - Assert.True(configurationObject.Keys.ToHashSet().SetEquals(new[] - { - "Size", - "Color", - "Platform:Id", - "Platform:Screens:0", - "Platform:Screens:1" - })); Assert.Equal("Medium", variant.Name); + Assert.Null(variant.ConfigurationObject); Assert.Equal("450px", variant.Configuration["Size"]); Assert.True(await featureManager.IsEnabledAsync(Features.VariantFeatureDefaultEnabled, cancellationToken)); diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index 9b555e7e..babce2a3 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -295,11 +295,7 @@ "name": "Medium", "configuration_value": { "Size": "450px", - "Color": "Purple", - "Platform": { - "Id": "mobile", - "Screens": ["fullhd", "tablet"] - } + "Color": "Purple" } }, { From 2fcd4140c70cbe67fd65ee57bca9f12d66232fe3 Mon Sep 17 00:00:00 2001 From: Stepami Date: Sat, 3 Oct 2026 23:21:41 +0300 Subject: [PATCH 8/8] feat(variants): cache config bind --- .../ConfigurationFeatureDefinitionProvider.cs | 7 +- .../FeatureManager.cs | 14 +- src/Microsoft.FeatureManagement/Variant.cs | 2 + .../VariantConfigurationCache.cs | 74 +++++++++ .../VariantDefinition.cs | 2 + .../VariantExtensions.cs | 12 ++ .../VariantConfigurationCacheTest.cs | 156 ++++++++++++++++++ .../VariantExtensionsTest.cs | 138 +++++++++++++++- 8 files changed, 401 insertions(+), 4 deletions(-) create mode 100644 src/Microsoft.FeatureManagement/VariantConfigurationCache.cs create mode 100644 tests/Tests.FeatureManagement/VariantConfigurationCacheTest.cs diff --git a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs index f648759e..3558e251 100644 --- a/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs +++ b/src/Microsoft.FeatureManagement/ConfigurationFeatureDefinitionProvider.cs @@ -544,6 +544,8 @@ private FeatureDefinition ParseMicrosoftSchemaFeatureDefinition(IConfigurationSe { StatusOverride statusOverride = StatusOverride.None; + IConfigurationSection variantConfiguration = section.GetSection(MicrosoftFeatureManagementFields.VariantDefinitionConfigurationValue); + string rawStatusOverride = section[MicrosoftFeatureManagementFields.VariantDefinitionStatusOverride]; if (!string.IsNullOrEmpty(rawStatusOverride)) @@ -554,7 +556,10 @@ private FeatureDefinition ParseMicrosoftSchemaFeatureDefinition(IConfigurationSe var variant = new VariantDefinition() { Name = section[MicrosoftFeatureManagementFields.Name], - ConfigurationValue = section.GetSection(MicrosoftFeatureManagementFields.VariantDefinitionConfigurationValue), + ConfigurationValue = variantConfiguration, + ConfigurationCache = variantConfiguration.Exists() + ? new VariantConfigurationCache(variantConfiguration) + : null, StatusOverride = statusOverride }; diff --git a/src/Microsoft.FeatureManagement/FeatureManager.cs b/src/Microsoft.FeatureManagement/FeatureManager.cs index a6ebed3c..0ebbbdd7 100644 --- a/src/Microsoft.FeatureManagement/FeatureManager.cs +++ b/src/Microsoft.FeatureManagement/FeatureManager.cs @@ -842,9 +842,18 @@ private Variant GetVariantFromVariantDefinition(VariantDefinition variantDefinit { IConfigurationSection variantConfiguration = null; - if (variantDefinition.ConfigurationValue.Exists()) + IConfigurationSection definitionConfiguration = variantDefinition.ConfigurationValue; + + if (definitionConfiguration?.Exists() == true) + { + variantConfiguration = definitionConfiguration; + } + + VariantConfigurationCache configurationCache = variantDefinition.ConfigurationCache; + + if (!ReferenceEquals(configurationCache?.Configuration, variantConfiguration)) { - variantConfiguration = variantDefinition.ConfigurationValue; + configurationCache = null; } return new Variant @@ -852,6 +861,7 @@ private Variant GetVariantFromVariantDefinition(VariantDefinition variantDefinit Name = variantDefinition.Name, Configuration = variantConfiguration, ConfigurationObject = variantDefinition.ConfigurationObject, + ConfigurationCache = configurationCache, }; } } diff --git a/src/Microsoft.FeatureManagement/Variant.cs b/src/Microsoft.FeatureManagement/Variant.cs index 012b5f3b..024cb233 100644 --- a/src/Microsoft.FeatureManagement/Variant.cs +++ b/src/Microsoft.FeatureManagement/Variant.cs @@ -25,5 +25,7 @@ public class Variant /// When set, variants should prefer this over . /// public object ConfigurationObject { get; set; } + + internal VariantConfigurationCache ConfigurationCache { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/VariantConfigurationCache.cs b/src/Microsoft.FeatureManagement/VariantConfigurationCache.cs new file mode 100644 index 00000000..cf30c654 --- /dev/null +++ b/src/Microsoft.FeatureManagement/VariantConfigurationCache.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.FeatureManagement +{ + internal sealed class VariantConfigurationCache + { + private readonly ConcurrentDictionary> _configurations = + new ConcurrentDictionary>(); + + public VariantConfigurationCache(IConfigurationSection configuration) + { + Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + } + + public IConfigurationSection Configuration { get; } + + public T GetConfiguration() + { + Type configurationType = typeof(T); + + if (_configurations.TryGetValue(configurationType, out Lazy configuration)) + { + return GetValue(configurationType, configuration); + } + + return GetOrAdd(() => Configuration.Get()); + } + + internal T GetOrAdd(Func valueFactory) + { + if (valueFactory == null) + { + throw new ArgumentNullException(nameof(valueFactory)); + } + + Type configurationType = typeof(T); + + if (!_configurations.TryGetValue(configurationType, out Lazy configuration)) + { + var newConfiguration = new Lazy( + () => valueFactory(), + LazyThreadSafetyMode.ExecutionAndPublication); + + configuration = _configurations.GetOrAdd(configurationType, newConfiguration); + } + + return GetValue(configurationType, configuration); + } + + private T GetValue(Type configurationType, Lazy configuration) + { + try + { + return (T)configuration.Value; + } + catch + { + // A failed binding should not permanently poison this configuration type. + ((ICollection>>)_configurations).Remove( + new KeyValuePair>(configurationType, configuration)); + + throw; + } + } + } +} diff --git a/src/Microsoft.FeatureManagement/VariantDefinition.cs b/src/Microsoft.FeatureManagement/VariantDefinition.cs index b26fb13e..63b5d18b 100644 --- a/src/Microsoft.FeatureManagement/VariantDefinition.cs +++ b/src/Microsoft.FeatureManagement/VariantDefinition.cs @@ -29,6 +29,8 @@ public class VariantDefinition /// public object ConfigurationObject { get; set; } + internal VariantConfigurationCache ConfigurationCache { get; set; } + /// /// Overrides the state of the feature if this variant has been assigned. /// diff --git a/src/Microsoft.FeatureManagement/VariantExtensions.cs b/src/Microsoft.FeatureManagement/VariantExtensions.cs index 61e2d76b..1cb76afb 100644 --- a/src/Microsoft.FeatureManagement/VariantExtensions.cs +++ b/src/Microsoft.FeatureManagement/VariantExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // + using Microsoft.Extensions.Configuration; namespace Microsoft.FeatureManagement @@ -20,6 +21,11 @@ public static class VariantExtensions /// otherwise, the configuration bound to from . /// Returns default when the variant or its configuration is absent. /// + /// + /// Provider-backed variants created from the same feature definition may share cached configuration instances. + /// Reloaded feature definitions use a new cache. + /// Callers should treat the returned configuration as read-only. + /// public static T GetConfiguration(this Variant variant) { if (variant == null) @@ -32,6 +38,12 @@ public static T GetConfiguration(this Variant variant) return typedConfigurationObject; } + if (variant.ConfigurationCache != null && + ReferenceEquals(variant.ConfigurationCache.Configuration, variant.Configuration)) + { + return variant.ConfigurationCache.GetConfiguration(); + } + return variant.Configuration != null ? variant.Configuration.Get() : default; diff --git a/tests/Tests.FeatureManagement/VariantConfigurationCacheTest.cs b/tests/Tests.FeatureManagement/VariantConfigurationCacheTest.cs new file mode 100644 index 00000000..763a4680 --- /dev/null +++ b/tests/Tests.FeatureManagement/VariantConfigurationCacheTest.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// + +using Microsoft.Extensions.Configuration; +using Microsoft.FeatureManagement; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Tests.FeatureManagement +{ + public class VariantConfigurationCacheTest + { + [Fact] + public void GetOrAddCachesNull() + { + VariantConfigurationCache cache = CreateCache(); + int factoryCalls = 0; + + Assert.Null(cache.GetOrAdd(() => + { + factoryCalls++; + return null; + })); + Assert.Null(cache.GetOrAdd(() => + { + factoryCalls++; + return "unexpected"; + })); + Assert.Equal(1, factoryCalls); + } + + [Fact] + public void GetOrAddRetriesAfterFailure() + { + VariantConfigurationCache cache = CreateCache(); + var expected = new object(); + int factoryCalls = 0; + + Assert.Throws(() => cache.GetOrAdd(() => + { + factoryCalls++; + throw new InvalidOperationException(); + })); + + Assert.Same(expected, cache.GetOrAdd(() => + { + factoryCalls++; + return expected; + })); + Assert.Equal(2, factoryCalls); + } + + [Fact] + public async Task GetOrAddInvokesFactoryOnceForConcurrentCalls() + { + const int CallerCount = 8; + VariantConfigurationCache cache = CreateCache(); + var expected = new object(); + using var callState = new ConcurrentCacheCallState(cache, expected, CallerCount); + var calls = new Task[CallerCount]; + + for (int i = 0; i < calls.Length; i++) + { + calls[i] = Task.Factory.StartNew( + state => ((ConcurrentCacheCallState)state).GetConfiguration(), + callState, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + } + + bool callersReady = callState.CallersReady.Wait(TimeSpan.FromSeconds(10)); + callState.StartCallers.Set(); + bool callersAtCache = callState.CallersAtCache.Wait(TimeSpan.FromSeconds(10)); + + object[] results = await Task.WhenAll(calls); + + Assert.True(callersReady); + Assert.True(callersAtCache); + Assert.Equal(1, callState.FactoryCalls); + Assert.False(callState.AdditionalFactoryStarted); + Assert.All(results, result => Assert.Same(expected, result)); + } + + private static VariantConfigurationCache CreateCache() + { + IConfigurationRoot configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + return new VariantConfigurationCache(configuration.GetSection("unused")); + } + + private sealed class ConcurrentCacheCallState : IDisposable + { + private readonly VariantConfigurationCache _cache; + private readonly object _expected; + private readonly ManualResetEventSlim _additionalFactoryStarted = new ManualResetEventSlim(); + private int _factoryCalls; + + public ConcurrentCacheCallState(VariantConfigurationCache cache, object expected, int callerCount) + { + _cache = cache; + _expected = expected; + CallersReady = new CountdownEvent(callerCount); + CallersAtCache = new CountdownEvent(callerCount); + StartCallers = new ManualResetEventSlim(); + } + + public CountdownEvent CallersReady { get; } + + public CountdownEvent CallersAtCache { get; } + + public ManualResetEventSlim StartCallers { get; } + + public int FactoryCalls => Volatile.Read(ref _factoryCalls); + + public bool AdditionalFactoryStarted => _additionalFactoryStarted.IsSet; + + public object GetConfiguration() + { + CallersReady.Signal(); + StartCallers.Wait(); + CallersAtCache.Signal(); + + return _cache.GetOrAdd(() => + { + int invocation = Interlocked.Increment(ref _factoryCalls); + + if (invocation == 1) + { + _additionalFactoryStarted.Wait(TimeSpan.FromSeconds(1)); + } + else + { + _additionalFactoryStarted.Set(); + } + + return _expected; + }); + } + + public void Dispose() + { + CallersReady.Dispose(); + CallersAtCache.Dispose(); + StartCallers.Dispose(); + _additionalFactoryStarted.Dispose(); + } + } + } +} diff --git a/tests/Tests.FeatureManagement/VariantExtensionsTest.cs b/tests/Tests.FeatureManagement/VariantExtensionsTest.cs index a47b5547..022f088a 100644 --- a/tests/Tests.FeatureManagement/VariantExtensionsTest.cs +++ b/tests/Tests.FeatureManagement/VariantExtensionsTest.cs @@ -1,16 +1,25 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. // + using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.Memory; using Microsoft.FeatureManagement; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Xunit; namespace Tests.FeatureManagement { public class VariantExtensionsTest { + private const string CachedFeatureName = "CachedVariantFeature"; + private const string CachedVariantName = "CachedVariant"; + + private const string ConfigurationValuePath = + "feature_management:feature_flags:0:variants:0:configuration_value:Value"; + [Fact] public void GetConfigurationReturnsNullForNullVariant() { @@ -64,12 +73,115 @@ public void GetConfigurationFallsBackToSection(bool hasIncompatibleObject) Configuration = configurationSection }; + AppContext firstConfiguration = variant.GetConfiguration(); + AppContext secondConfiguration = variant.GetConfiguration(); + Assert.Equivalent(new AppContext { AccountId = "1", UserId = "2", Groups = new List { "Chrome", "Edge" } - }, variant.GetConfiguration()); + }, firstConfiguration); + Assert.NotSame(firstConfiguration, secondConfiguration); + } + + [Fact] + public async Task GetConfigurationCachesProviderConfigurationByTypeAcrossVariants() + { + var configuration = CreateVariantConfiguration("initial"); + using var provider = new ConfigurationFeatureDefinitionProvider(configuration); + var featureManager = new FeatureManager(provider); + + Variant firstVariant = await featureManager.GetVariantAsync(CachedFeatureName); + Variant secondVariant = await featureManager.GetVariantAsync(CachedFeatureName); + + CachedVariantConfiguration firstConfiguration = firstVariant.GetConfiguration(); + AlternateCachedVariantConfiguration alternateConfiguration = + firstVariant.GetConfiguration(); + + Assert.NotSame(firstVariant, secondVariant); + Assert.Equal("initial", firstConfiguration.Value); + Assert.Equal("initial", alternateConfiguration.Value); + Assert.Same(firstConfiguration, firstVariant.GetConfiguration()); + Assert.Same(firstConfiguration, secondVariant.GetConfiguration()); + Assert.Same(alternateConfiguration, secondVariant.GetConfiguration()); + Assert.NotSame(firstConfiguration, alternateConfiguration); + } + + [Fact] + public async Task GetConfigurationUsesNewCacheAfterConfigurationReload() + { + var configuration = CreateVariantConfiguration("before"); + using var provider = new ConfigurationFeatureDefinitionProvider(configuration); + var featureManager = new FeatureManager(provider); + + Variant beforeVariant = await featureManager.GetVariantAsync(CachedFeatureName); + CachedVariantConfiguration beforeConfiguration = + beforeVariant.GetConfiguration(); + + configuration.Providers.Last().Set(ConfigurationValuePath, "after"); + configuration.Reload(); + + Variant afterVariant = await featureManager.GetVariantAsync(CachedFeatureName); + CachedVariantConfiguration afterConfiguration = afterVariant.GetConfiguration(); + + Assert.Equal("before", beforeConfiguration.Value); + Assert.Equal("after", afterConfiguration.Value); + Assert.NotSame(beforeConfiguration, afterConfiguration); + Assert.Same(beforeConfiguration, beforeVariant.GetConfiguration()); + Assert.Same(afterConfiguration, afterVariant.GetConfiguration()); + } + + [Fact] + public async Task GetConfigurationBypassesProviderCacheAfterConfigurationIsReplaced() + { + var configuration = CreateVariantConfiguration("initial"); + using var provider = new ConfigurationFeatureDefinitionProvider(configuration); + var featureManager = new FeatureManager(provider); + Variant variant = await featureManager.GetVariantAsync(CachedFeatureName); + CachedVariantConfiguration initialConfiguration = variant.GetConfiguration(); + var replacementConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["replacement:Value"] = "replacement" + }) + .Build(); + + variant.Configuration = replacementConfiguration.GetSection("replacement"); + + CachedVariantConfiguration firstReplacement = variant.GetConfiguration(); + CachedVariantConfiguration secondReplacement = variant.GetConfiguration(); + + Assert.Equal("initial", initialConfiguration.Value); + Assert.Equal("replacement", firstReplacement.Value); + Assert.Equal("replacement", secondReplacement.Value); + Assert.NotSame(initialConfiguration, firstReplacement); + Assert.NotSame(firstReplacement, secondReplacement); + } + + [Fact] + public async Task GetConfigurationBypassesProviderCacheAfterDefinitionConfigurationIsReplaced() + { + var configuration = CreateVariantConfiguration("initial"); + using var provider = new ConfigurationFeatureDefinitionProvider(configuration); + FeatureDefinition definition = await provider.GetFeatureDefinitionAsync(CachedFeatureName); + VariantDefinition variantDefinition = Assert.Single(definition.Variants); + var replacementConfiguration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["replacement:Value"] = "replacement" + }) + .Build(); + IConfigurationSection replacementSection = replacementConfiguration.GetSection("replacement"); + + variantDefinition.ConfigurationValue = replacementSection; + + var featureManager = new FeatureManager(provider); + Variant variant = await featureManager.GetVariantAsync(CachedFeatureName); + + Assert.Same(replacementSection, variant.Configuration); + Assert.Null(variant.ConfigurationCache); + Assert.Equal("replacement", variant.GetConfiguration().Value); } [Fact] @@ -94,5 +206,29 @@ public void GetConfigurationReturnsNullWithoutConfiguration() Assert.Null(new Variant().GetConfiguration()); Assert.Null(new Variant().GetConfiguration()); } + + private static IConfigurationRoot CreateVariantConfiguration(string value) + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["feature_management:feature_flags:0:id"] = CachedFeatureName, + ["feature_management:feature_flags:0:enabled"] = bool.TrueString, + ["feature_management:feature_flags:0:variants:0:name"] = CachedVariantName, + [ConfigurationValuePath] = value, + ["feature_management:feature_flags:0:allocation:default_when_enabled"] = CachedVariantName + }) + .Build(); + } + + private sealed class CachedVariantConfiguration + { + public string Value { get; set; } + } + + private sealed class AlternateCachedVariantConfiguration + { + public string Value { get; set; } + } } }