blog post image
Andrew Lock avatar

Andrew Lock

~8 min read

Ensuring consistent feature flags across requests

Adding feature flags to an ASP.NET Core app - Part 5

In the first and second posts in this series, I introduced the Microsoft.FeatureManagement, and Microsoft.FeatureManagement.AspNetCore libraries, and showed how to use them to add feature flags to an ASP.NET Core app.

In the third and fourth posts, I showed how to use feature filters, which let you enable a feature flag based on arbitrary data. For example, you could enable a feature based on headers in an incoming request, based on the current time, or for a certain percentage of users.

I touched on a problem with one such filter, the PercentageFilter, in the third post. This filter returns true for IsEnabled() 10% of the time (for example). Unfortunately, if you call the filter multiple times within a single request you could get different results. Not to mention that each request could give a different result for the same user.

In this post I describe two ways to improve the consistency of your feature flags for users. The first approach, using IFeatureManagerSnapshot ensures consistency within a given request. The second approach, using a custom ISessionManager, allows you to extend this consistency between requests.

The problem: different results with every request.

To demonstrate the problem, I'll create a feature flag using the PercentageFilter as described in my previous post. I'll use this filter to set a feature flag called "NewExperience", which is configured to be enabled 50% of the time:

{
  "FeatureManagement": {
    "NewExperience": {
      "EnabledFor": [
        {
          "Name": "Microsoft.Percentage",
          "Parameters": {
            "Value": 50
          }
        }
      ]
    }
  }

On the home page of the app, I'll inject an IFeatureManager into Index.cshtml and request the value of the feature 10 times:

@page
@model IndexModel
@inject Microsoft.FeatureManagement.IFeatureManager FeatureManager
@{
    ViewData["Title"] = "Home page";
}

<ul>
@for (var i = 0; i < 10; i++)
{
    <li>Flag is: @FeatureManager.IsEnabled(FeatureFlags.NewExperience)</li>
}
</ul>

If you run the application, you can see that every time you call IsEnabled, there's a 50% chance the flag will be enabled:

Using IFeatureManager, every call to IsEnabled is independent

This is very unlikely to be desirable in your application. You almost certainly don't want a flag to flip between enabled and disabled within the context of the same request! Depending on the level of consistency you need, there are two main approaches to solving this. The first is to use IFeatureManagerSnapshot.

Maintaining consistency within a request with IFeatureManagerSnapshot

IFeatureManagerSnapshot is registered with the DI container by default when you use feature flags, and acts as a per-request cache for feature flags. It derives from IFeatureManager, so you use it in exactly the same way. You can simply replace the IFeatureManager references with IFeatureManagerSnapshot:

@page
@model IndexModel
@inject Microsoft.FeatureManagement.IFeatureManagerSnapshot SnapshotManager
@{
    ViewData["Title"] = "Home page";
}

<ul>
@for (var i = 0; i < 10; i++)
{
    <li>Flag is: @SnapshotManager.IsEnabled(FeatureFlags.NewExperience)</li>
}
</ul>

If you refresh the page a few times you'll see that within a request, all calls to IsEnabled() return the same value. However, between requests, there's a 50% chance you'll be flipped back and forth between enabled and disabled:

Using IFeatureManagerSnapshot, all calls to IsEnabled within a request return the same value

Depending on what you're using your feature flags for, this may be sufficient for your needs. This approach also has an advantage if you have a feature filter that is expensive to calculate - using IFeatureManagerSnapshot caches the value for the whole request, instead of repeatedly re-evaluating it. In general, I feel like IFeatureManagerSnapshot should be the interface you reach for in nearly all cases.

However, it won't solve all your problems. I expect that for most applications, you'll want a PercentageFilter feature flag to persist for all requests by a given user, so the user doesn't see features flipping on and off. If that's the case, you'll want to take a look at ISessionManager.

Maintaining consistency between requests with ISessionManager

You can think of ISessionManager as a bit like IFeatureManagerSnapshot on steroids - it's a glorified dictionary of feature flag check results, but you can store the data anywhere you like. An obvious choice might be to use the ASP.NET Core Session feature to store the feature flag results. This would ensure that once a user checks a feature flag, the result (enabled or disabled) persists for that user. That prevents the "flipping back and forth" issue described above.

ISessionManager is a small interface to implement, consisting of just two methods:

public interface ISessionManager
{
    void Set(string featureName, bool enabled);
    bool TryGet(string featureName, out bool enabled);
}

Set() is called after the value of a feature flag has been determined for the first time, and is used to store the result. TryGet() is called every time IFeatureManager.IsEnabled() is called, to check if a value for the flag has previously been set. If it has, TryGet() returns true, and enabled contains the feature flag result.

The example implementation below shows an ISessionManager implementation that uses the ASP.NET Core Session to store feature flag results:

public class SessionSessionManager : ISessionManager
{
    private readonly IHttpContextAccessor _contextAccessor;
    public SessionSessionManager(IHttpContextAccessor contextAccessor)
    {
        _contextAccessor = contextAccessor;
    }

    public void Set(string featureName, bool enabled)
    {
        var session = _contextAccessor.HttpContext.Session;
        var sessionKey = $"feature_{featureName}";
        session.Set(sessionKey, new[] {enabled ? (byte) 1 : (byte) 0});
    }

    public bool TryGet(string featureName, out bool enabled)
    {
        var session = _contextAccessor.HttpContext.Session;
        var sessionKey = $"feature_{featureName}";
        if (session.TryGetValue(sessionKey, out var enabledBytes))
        {
            enabled = enabledBytes[0] == 1;
            return true;
        }

        enabled = false;
        return false;
    }
}

As this is using Session (which uses a cookie for setting the session ID), you need to access the HttpContext, so you need to use the HttpContextAccessor. The implementation above is a very thin wrapper around Session: you store the flag result as either a 0 or 1, using a different key for each filter, and retrieve the value using the same key.

To enable the ISessionManager you need to add it to the DI container. You also need to add the ASP.NET Core Session services and middleware for this implementation.

Note that ISessionManager and the FeatureManagement library itself has no dependence on ASP.NET Core Session - it's only required because I chose to use it in this implementation.

Register the ISessionManager and dependent services in Startup.ConfigureServices():

public void ConfigureServices(IServiceCollection services)
{
    // ...
    services.AddSession();
    services.AddHttpContextAccessor();
    services.AddTransient<ISessionManager, SessionSessionManager>();

    services.AddFeatureManagement()
        .AddFeatureFilter<PercentageFilter>();
}

Also, add the session middleware to your middleware pipeline in Startup.Configure(), just before the MVC middleware:

public void Configure(IApplicationBuilder app)
{
    // ...
    app.UseSession();
    app.UseMvc();
}

Remember, the ASP.NET session will be empty until after the session middleware has run, so the ISessionManager will also be empty until then. If you're going to be using feature management early in your middleware pipeline, then you'll want to add the session middleware earlier too.

Repeating the same experiment with the ISessionManager in place means you will get consistent feature flags across all requests for the user, until their session ends, or they switch browsers. If you need an even higher degree of consistency (tied to the user's ID rather than their session, so that flags persist browsers, for example) you could implement a different ISessionManager.

Using ISessionManager, all calls to IsEnabled while a session exists return the same value

Overall, ISessionManager is clearly designed to solve the consistency problem, but I think you'll need to be careful how it's used, as the basic implementation shown in this post probably won't cut the mustard.

Limitations with the ISessionManger implementation

The main issue with my implementation of ISessionManager is that it caches the values of all feature flags in Session. That may be fine, but if you have other feature filters then it's almost certainly not.

Take the TimeWindowFilter from my previous post, for example. This filter should return false unless the current time is between the configured values. We don't want the ISessionManager to cache the value for an entire session, otherwise the feature filter may never turn on, even as time progresses!

There is a solution to this - restrict the ISessionManager to caching a single feature. For example:

public class NewExperienceSessionManager : ISessionManager
{
    public void Set(string featureName, bool enabled)
    {
        if(featureName != FeatureFlags.NewExperience) { return; }
        // ... implementation as before
    }

    public bool TryGet(string featureName, out bool enabled)
    {
        if(featureName != FeatureFlags.NewExperience) 
        { 
            enabled = false;
            return false;
        }
        // ... implementation as before
    }
}

This approach would technically deal with the issue. But at this point you're looking at creating an ISessionManager per feature. That may be fine; I'm undecided. Personally I think it would be nice to be able to explicitly configure an ISessionManager for each feature in configuration, rather than have the additional layer of indirection the current design requires.

Note that you can register multiple instances of ISessionManager with the DI container, and the IFeatureManager will call Set() and TryGet() on all of them, hence the ISessionManager-per-feature approach would work ok.

Another possible approach would be to avoid the ISessionManager entirely, and instead create feature filters that return a consistent value based on the current user. For example, you could create a custom PercentageFilter that uses the current user's ID as the seed for the random number generator, so the same user ID always returns the same value. This has its own limitations, but it removes the need for an ISessionManager entirely.

Another important limitation, and one that applies to the whole Microsoft.FeatureManagement library, is that the APIs are all synchronous. No async/await allowed.

This hasn't been a problem in my initial tests with the library, but I could easily imagine a feature filter that requires a database lookup, or an HTTP call - those all become dangerous as you're stuck doing sync-over-async. The library fundamentally isn't designed to handle adhoc async tasks like this, which limits the approaches you can take. Any slow or async tasks must run in the background and update the underlying configuration, which the feature filter can then use.

Summary

In this post I described some of the challenges in maintaining consistent values for your feature flags, especially when using the PecentageFilter, which randomly decides if a flag is enabled every time IsEnabled() is called. I showed how to use IFeatureManagerSnapshot to maintain consistency in feature flag results within a given request. I also introduced ISessionManager which can be used to cache feature flag results between requests. The example implementation I provided uses ASP.NET Core Session to cache the results for a user. This approach has some limitations that are important to bear in mind when implementing in your own projects.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?