Skip to content

Adds 'Settings' to Context and uses it as a cache of parameters #229

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jun 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
91617b2
Adds 'Settings' to Context and uses it as a cache of parameters
rossgrambo Apr 19, 2023
69a3f8d
Resolves comments
rossgrambo May 4, 2023
a3d3a2b
Changes the default cache period to 0
rossgrambo May 4, 2023
79c0206
Handling edge case where AbsoluteExpirationRelative doesn't accept Ti…
rossgrambo May 4, 2023
ed9834c
Renamed SettingsCachePeriod to FilterSettingsCacheTtl and adjusted su…
rossgrambo May 4, 2023
87a3799
Removing unrequired dependency
rossgrambo May 4, 2023
95abd68
Adds an Error type for invalid options and throws it for negative cac…
rossgrambo May 5, 2023
20e6b2c
Changed options validation error to an ArgumentException
rossgrambo May 5, 2023
c1a7240
Merge branch 'main' into rossgrambo/cache-filter-parameters
rossgrambo May 5, 2023
576b762
Removes cache timespan as an option, sets a sliding expiration to 5 m…
rossgrambo May 13, 2023
541bfc0
Merge branch 'rossgrambo/cache-filter-parameters' of https://github.c…
rossgrambo May 13, 2023
c44de57
Adds FilterIndex to FeatureFilterEvaluationContext. Uses it in the ca…
rossgrambo May 13, 2023
d2209ae
Changes the interface to be private and adds an assemblyinfo file tha…
rossgrambo May 15, 2023
c18384c
Pulling out constants and adding notes to summary
rossgrambo May 23, 2023
5cce4d3
Merge branch 'main' into rossgrambo/cache-filter-parameters
rossgrambo May 30, 2023
e1044ef
Removes FilterIndex from FeatureFilterEvaluationContext object and cl…
rossgrambo Jun 1, 2023
c15d820
Merge branch 'rossgrambo/cache-filter-parameters' of https://github.c…
rossgrambo Jun 1, 2023
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
12 changes: 12 additions & 0 deletions src/Microsoft.FeatureManagement/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using System.Runtime.CompilerServices;

// Tests
[assembly: InternalsVisibleTo("Tests.FeatureManagement,PublicKey=" +
"0024000004800000940000000602000000240000525341310004000001000100895524f60b44ff" +
"3ae70fbea5662f61dd9d640de2205b7bd5359a43dda006e51d83d1f5f7a7d3f849267a0a28676d" +
"cf49727a32487d4c75c4aacd5febb0069e1adc66ec63bbd18ec2276091a0e3c1326aa626c9e4db" +
"800714a134f2a81e405f35752b55220021923429cb61776cd2fa66d25c335f8dc27bb92292905a" +
"3798d896")]
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ namespace Microsoft.FeatureManagement
/// <summary>
/// A feature definition provider that pulls feature definitions from the .NET Core <see cref="IConfiguration"/> system.
/// </summary>
sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionProvider, IDisposable
sealed class ConfigurationFeatureDefinitionProvider : IFeatureDefinitionProvider, IDisposable, IFeatureDefinitionProviderCacheable
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like if we have some comment explaining that IFeatureDefinitionProviderCacheable is around purely for satisfying test consumption.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps also in that types file as well.

{
//
// IFeatureDefinitionProviderCacheable interface is only used to mark this provider as cacheable. This allows our test suite's
// provider to be marked for caching as well.

private const string FeatureFiltersSectionName = "EnabledFor";
private const string RequirementTypeKeyword = "RequirementType";
private readonly IConfiguration _configuration;
Expand Down Expand Up @@ -179,7 +183,7 @@ We support
enabledFor.Add(new FeatureFilterConfiguration()
{
Name = section[nameof(FeatureFilterConfiguration.Name)],
Parameters = section.GetSection(nameof(FeatureFilterConfiguration.Parameters))
Parameters = new ConfigurationWrapper(section.GetSection(nameof(FeatureFilterConfiguration.Parameters)))
});
}
}
Expand Down
39 changes: 39 additions & 0 deletions src/Microsoft.FeatureManagement/ConfigurationWrapper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Primitives;

namespace Microsoft.FeatureManagement
{
/// <summary>
/// Wraps an instance of IConfiguration. This allows the reference to be updated when the underlying IConfiguration is updated.
/// This is useful for cache busting based on the reference.
/// </summary>
class ConfigurationWrapper : IConfiguration
{
private readonly IConfiguration _configuration;

public ConfigurationWrapper(IConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}

public string this[string key]
{
get => _configuration[key];
set => _configuration[key] = value;
}

public IEnumerable<IConfigurationSection> GetChildren() =>
_configuration.GetChildren();

public IChangeToken GetReloadToken() =>
_configuration.GetReloadToken();

public IConfigurationSection GetSection(string key) =>
_configuration.GetSection(key);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,11 @@ public class FeatureFilterEvaluationContext
/// The settings provided for the feature filter to use when evaluating whether the feature should be enabled.
/// </summary>
public IConfiguration Parameters { get; set; }

/// <summary>
/// A settings object, if any, that has been pre-bound from <see cref="Parameters"/>.
/// The settings are made available for <see cref="IFeatureFilter"/>s that implement <see cref="IFilterParametersBinder"/>.
/// </summary>
public object Settings { get; set; }
}
}
14 changes: 12 additions & 2 deletions src/Microsoft.FeatureManagement/FeatureFilters/PercentageFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters
/// A feature filter that can be used to activate a feature based on a random percentage.
/// </summary>
[FilterAlias(Alias)]
public class PercentageFilter : IFeatureFilter
public class PercentageFilter : IFeatureFilter, IFilterParametersBinder
{
private const string Alias = "Microsoft.Percentage";
private readonly ILogger _logger;
Expand All @@ -26,14 +26,24 @@ public PercentageFilter(ILoggerFactory loggerFactory)
_logger = loggerFactory.CreateLogger<PercentageFilter>();
}

/// <summary>
/// Binds configuration representing filter parameters to <see cref="PercentageFilterSettings"/>.
/// </summary>
/// <param name="filterParameters">The configuration representing filter parameters that should be bound to <see cref="PercentageFilterSettings"/>.</param>
/// <returns><see cref="PercentageFilterSettings"/> that can later be used in feature evaluation.</returns>
public object BindParameters(IConfiguration filterParameters)
{
return filterParameters.Get<PercentageFilterSettings>() ?? new PercentageFilterSettings();
}

/// <summary>
/// Performs a percentage based evaluation to determine whether a feature is enabled.
/// </summary>
/// <param name="context">The feature evaluation context.</param>
/// <returns>True if the feature is enabled, false otherwise.</returns>
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
PercentageFilterSettings settings = context.Parameters.Get<PercentageFilterSettings>() ?? new PercentageFilterSettings();
PercentageFilterSettings settings = (PercentageFilterSettings)context.Settings;

bool result = true;

Expand Down
14 changes: 12 additions & 2 deletions src/Microsoft.FeatureManagement/FeatureFilters/TimeWindowFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ namespace Microsoft.FeatureManagement.FeatureFilters
/// A feature filter that can be used to activate a feature based on a time window.
/// </summary>
[FilterAlias(Alias)]
public class TimeWindowFilter : IFeatureFilter
public class TimeWindowFilter : IFeatureFilter, IFilterParametersBinder
{
private const string Alias = "Microsoft.TimeWindow";
private readonly ILogger _logger;
Expand All @@ -26,14 +26,24 @@ public TimeWindowFilter(ILoggerFactory loggerFactory)
_logger = loggerFactory.CreateLogger<TimeWindowFilter>();
}

/// <summary>
/// Binds configuration representing filter parameters to <see cref="TimeWindowFilterSettings"/>.
/// </summary>
/// <param name="filterParameters">The configuration representing filter parameters that should be bound to <see cref="TimeWindowFilterSettings"/>.</param>
/// <returns><see cref="TimeWindowFilterSettings"/> that can later be used in feature evaluation.</returns>
public object BindParameters(IConfiguration filterParameters)
{
return filterParameters.Get<TimeWindowFilterSettings>() ?? new TimeWindowFilterSettings();
}

/// <summary>
/// Evaluates whether a feature is enabled based on a configurable time window.
/// </summary>
/// <param name="context">The feature evaluation context.</param>
/// <returns>True if the feature is enabled, false otherwise.</returns>
public Task<bool> EvaluateAsync(FeatureFilterEvaluationContext context)
{
TimeWindowFilterSettings settings = context.Parameters.Get<TimeWindowFilterSettings>() ?? new TimeWindowFilterSettings();
TimeWindowFilterSettings settings = (TimeWindowFilterSettings)context.Settings;

DateTimeOffset now = DateTimeOffset.UtcNow;

Expand Down
92 changes: 86 additions & 6 deletions src/Microsoft.FeatureManagement/FeatureManager.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
Expand All @@ -14,15 +16,26 @@ namespace Microsoft.FeatureManagement
/// <summary>
/// Used to evaluate whether a feature is enabled or disabled.
/// </summary>
class FeatureManager : IFeatureManager
class FeatureManager : IFeatureManager, IDisposable
{
private readonly TimeSpan ParametersCacheSlidingExpiration = TimeSpan.FromMinutes(5);
private readonly TimeSpan ParametersCacheAbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1);

private readonly IFeatureDefinitionProvider _featureDefinitionProvider;
private readonly IEnumerable<IFeatureFilterMetadata> _featureFilters;
private readonly IEnumerable<ISessionManager> _sessionManagers;
private readonly ILogger _logger;
private readonly ConcurrentDictionary<string, IFeatureFilterMetadata> _filterMetadataCache;
private readonly ConcurrentDictionary<string, ContextualFeatureFilterEvaluator> _contextualFeatureFilterCache;
private readonly FeatureManagementOptions _options;
private readonly IMemoryCache _parametersCache;

private class ConfigurationCacheItem
{
public IConfiguration Parameters { get; set; }

public object Settings { get; set; }
}

public FeatureManager(
IFeatureDefinitionProvider featureDefinitionProvider,
Expand All @@ -38,6 +51,7 @@ public FeatureManager(
_filterMetadataCache = new ConcurrentDictionary<string, IFeatureFilterMetadata>();
_contextualFeatureFilterCache = new ConcurrentDictionary<string, ContextualFeatureFilterEvaluator>();
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_parametersCache = new MemoryCache(new MemoryCacheOptions());
}

public Task<bool> IsEnabledAsync(string feature)
Expand All @@ -58,6 +72,11 @@ public async IAsyncEnumerable<string> GetFeatureNamesAsync()
}
}

public void Dispose()
{
_parametersCache.Dispose();
}

private async Task<bool> IsEnabledAsync<TContext>(string feature, TContext appContext, bool useAppContext)
{
foreach (ISessionManager sessionManager in _sessionManagers)
Expand Down Expand Up @@ -99,10 +118,16 @@ private async Task<bool> IsEnabledAsync<TContext>(string feature, TContext appCo
// We iterate until we hit our target evaluation
bool targetEvaluation = !enabled;

//
// Keep track of the index of the filter we are evaluating
int filterIndex = -1;

//
// For all enabling filters listed in the feature's state, evaluate them according to requirement type
foreach (FeatureFilterConfiguration featureFilterConfiguration in featureDefinition.EnabledFor)
{
filterIndex++;

//
// Handle AlwaysOn filters
if (string.Equals(featureFilterConfiguration.Name, "AlwaysOn", StringComparison.OrdinalIgnoreCase))
Expand Down Expand Up @@ -144,7 +169,9 @@ private async Task<bool> IsEnabledAsync<TContext>(string feature, TContext appCo
{
ContextualFeatureFilterEvaluator contextualFilter = GetContextualFeatureFilter(featureFilterConfiguration.Name, typeof(TContext));

if (contextualFilter != null &&
BindSettings(filter, context, filterIndex);

if (contextualFilter != null &&
await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false) == targetEvaluation)
{
enabled = targetEvaluation;
Expand All @@ -155,12 +182,15 @@ await contextualFilter.EvaluateAsync(context, appContext).ConfigureAwait(false)

//
// IFeatureFilter
if (filter is IFeatureFilter featureFilter &&
await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvaluation)
if (filter is IFeatureFilter featureFilter)
{
enabled = targetEvaluation;
BindSettings(filter, context, filterIndex);

if (await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvaluation) {
enabled = targetEvaluation;

break;
break;
}
}
}
}
Expand All @@ -187,6 +217,56 @@ await featureFilter.EvaluateAsync(context).ConfigureAwait(false) == targetEvalua
return enabled;
}

private void BindSettings(IFeatureFilterMetadata filter, FeatureFilterEvaluationContext context, int filterIndex)
{
IFilterParametersBinder binder = filter as IFilterParametersBinder;

if (binder == null)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about having a separate short circuit like

if (!(_featureDefinitionProvider is IFeatureDefinitionProviderCacheable))
{
    context.Settings = binder.BindParameters(context.Parameters);

    return;
}

Suggesting it because any interaction with cache key or cache doesn't make sense if the definition provider isn't cachable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, makes the cache if statement a little cleaner as a result.

{
return;
}

if (!(_featureDefinitionProvider is IFeatureDefinitionProviderCacheable))
{
context.Settings = binder.BindParameters(context.Parameters);

return;
}

object settings;

ConfigurationCacheItem cacheItem;

string cacheKey = $"{context.FeatureName}.{filterIndex}";

//
// Check if settings already bound from configuration or the parameters have changed
if (!_parametersCache.TryGetValue(cacheKey, out cacheItem) ||
cacheItem.Parameters != context.Parameters)
{
settings = binder.BindParameters(context.Parameters);

_parametersCache.Set(
cacheKey,
new ConfigurationCacheItem
{
Settings = settings,
Parameters = context.Parameters
},
new MemoryCacheEntryOptions
{
SlidingExpiration = ParametersCacheSlidingExpiration,
AbsoluteExpirationRelativeToNow = ParametersCacheAbsoluteExpirationRelativeToNow
});
}
else
{
settings = cacheItem.Settings;
}

context.Settings = settings;
}

private IFeatureFilterMetadata GetFeatureFilterMetadata(string filterName)
{
const string filterSuffix = "filter";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//

namespace Microsoft.FeatureManagement
{
/// <summary>
/// An interface that marks this provider's parameters are cacheable. This was implemented to allow the provider in our test suite to be cacheable.
/// </summary>
internal interface IFeatureDefinitionProviderCacheable
{
}
}
21 changes: 21 additions & 0 deletions src/Microsoft.FeatureManagement/IFilterParametersBinder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
using Microsoft.Extensions.Configuration;

namespace Microsoft.FeatureManagement
{
/// <summary>
/// An interface used by the feature management system to pre-bind feature filter parameters to a settings type.
/// <see cref="IFeatureFilter"/>s can implement this interface to take advantage of caching of settings by the feature management system.
/// </summary>
public interface IFilterParametersBinder
{
/// <summary>
/// Binds a set of feature filter parameters to a settings object.
/// </summary>
/// <param name="parameters">The configuration representing filter parameters to bind to a settings object</param>
/// <returns>A settings object that is understood by the implementer of <see cref="IFilterParametersBinder"/>.</returns>
object BindParameters(IConfiguration parameters);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="2.1.23" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="2.1.10" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="2.1.1" />
</ItemGroup>
Expand Down
Loading