Loading feature flags from new endpoint#738
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds support for loading feature flags from Azure App Configuration’s newer feature-flag endpoint and introduces an option to exclude “classic” feature flags stored under the .appconfig.featureflag/ key namespace.
Changes:
- Add opt-in behavior to exclude classic feature flags and prefer the new feature-flag endpoint as the source of truth.
- Introduce “new feature-flag selector/watcher” markers and update selector equality/hash behavior accordingly.
- Implement loading + change detection for the new feature-flag endpoint, including synthesis into the existing feature-management JSON schema.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueWatcher.cs | Adds a flag to distinguish watchers targeting the new feature-flag endpoint. |
| src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Models/KeyValueSelector.cs | Adds a flag to distinguish selectors for the new endpoint and updates equality/hash logic. |
| src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagSettingConverter.cs | New converter that synthesizes ConfigurationSetting instances from SDK FeatureFlag models using the existing feature-management JSON schema. |
| src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureFlagOptions.cs | Adds a new public option to exclude classic feature flags. |
| src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/ConfigurationClientExtensions.cs | Adds change detection for the new feature-flag endpoint by comparing page ETags. |
| src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs | Routes loading/refresh logic between classic key-values and the new feature-flag endpoint; ensures new flags win on key conflicts. |
| src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationOptions.cs | Updates UseFeatureFlags to register new-endpoint selectors/watchers (and optionally omit classic flags); changes default SDK service version. |
| // The new endpoint uses null to mean "any name". A bare "*" is equivalent. | ||
| if (string.IsNullOrEmpty(nameFilter) || nameFilter == "*") | ||
| { | ||
| nameFilter = null; | ||
| } |
| private static ConfigurationClientOptions GetDefaultClientOptions() | ||
| { | ||
| var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2023_11_01); | ||
| var clientOptions = new ConfigurationClientOptions(ConfigurationClientOptions.ServiceVersion.V2026_05_01_Preview); | ||
| clientOptions.Retry.MaxRetries = MaxRetries; |
| /// <summary> | ||
| /// When set to true, classic feature flags (key-values whose key is prefixed with | ||
| /// ".appconfig.featureflag/") are not loaded from the configuration store. Only feature flags | ||
| /// returned by the new feature-flag endpoint will be loaded. | ||
| /// Defaults to false; classic flags are loaded alongside new flags for backward compatibility. | ||
| /// </summary> | ||
| public bool ExcludeClassicFeatureFlags { get; set; } = false; |
|
This needs to go into the preview branch. |
708b5b1 to
56f906e
Compare
| ETag etag = featureFlag.Etag ?? default; | ||
| FeatureManagementKeyValueAdapter featureFlagAdapter = _options.Adapters | ||
| .OfType<FeatureManagementKeyValueAdapter>() | ||
| .FirstOrDefault(); |
There was a problem hiding this comment.
We shouldn't have the code looking like we may not have what we need to convert a feature flag. Here's what I think we can do to make it more explicit.
Add a static type static class FeatureFlagConverter that has a public method
public IEnumerable<KeyValuePair<string, string>> ToConfiguration(FeatureFlag flag).
Adapters are for processing configuration settings. The new feature flags aren't configuration settings so we don't need an adapter.
There was a problem hiding this comment.
.NET config binds feature_management:feature_flags as an array, classic flags get indices 0..N-1, then standalone continue N..N+M-1. For FeatureFlagConverter, we need _featureFlagIndex, _featureFlagTracing and Endpoint for FeatureFlagReference.
How about
public static IEnumerable<KeyValuePair<string, string>> ToConfiguration(
FeatureFlag flag,
ref int featureFlagIndex,
Uri endpoint,
FeatureFlagTracing tracing)
| FeatureFlag flag, | ||
| ref int featureFlagIndex, | ||
| Uri endpoint, | ||
| FeatureFlagTracing tracing) |
There was a problem hiding this comment.
Tracing updating should be separated from conversion to configuration.
| // uses a single contiguous set of indices. | ||
| int featureFlagIndex = _options.Adapters | ||
| .OfType<FeatureManagementKeyValueAdapter>() | ||
| .FirstOrDefault()?.FeatureFlagIndex ?? 0; |
There was a problem hiding this comment.
What about on refresh, does the index keep incrementing? It seems like we should track index only in the provider. I think we need to move away from the adapter pattern for feature flags.
There was a problem hiding this comment.
Discussed offline. Use converter for the classic and new ffs. When process classic ff, there're Dotnet ff schema and Microsoft ff schema, so the ff index is not equal to the classicFf.count() and invisible to the caller after the conversion. Working in progress for this part.
There was a problem hiding this comment.
so the ff index is not equal to the classicFf.count()
But it's fine to start at classicFfCount right? The only issue would be if we started at an index that was already occupied. No issue if we skip an index.
There was a problem hiding this comment.
Oh I see. I thought the index must be contiguous. Yes, we can start at classicFFCount. Will update the code
| { | ||
| _options.FeatureFlagTracing.Update(featureFlag); | ||
|
|
||
| string featureFlagPath = $"{FeatureManagementConstants.FeatureManagementSectionName}:{FeatureManagementConstants.FeatureFlagsSectionName}:{featureFlagIndex}"; |
There was a problem hiding this comment.
Why isn't this part of FeatureFlagConverter.ToConfiguration?
There was a problem hiding this comment.
It should be in ToConfiguration(). Updated.
| // continue the "feature_management:feature_flags" array from this index so that the combined array | ||
| // uses a single contiguous set of indices. Classic flags emitted using the .NET schema do not | ||
| // occupy an array slot and therefore do not advance this count. | ||
| _classicFeatureFlagCount = featureFlagIndex; |
There was a problem hiding this comment.
I thought we would be able to use the classic feature flag count and simplify things. The worse thing I could think was an index might be skipped which isn't an issue.
There was a problem hiding this comment.
Updated, use classic feature flag count now.
| Dictionary<string, ConfigurationSetting> data, | ||
| ClassicFeatureFlagLoadResult classicFeatureFlagLoadResult) | ||
| { | ||
| if (classicFeatureFlagLoadResult?.ClassicFeatureFlags != null) |
There was a problem hiding this comment.
Based off when we call this, is it ever expected to be null? I would expect no.
There was a problem hiding this comment.
It's not clear to me that this is worth a helper method.
There was a problem hiding this comment.
Yes, it won't be null.
Removed helper method.
ffKeys = new HashSet<string>(
classicFeatureFlagLoadResult.ClassicFeatureFlags.Select(ff => ff.Key)
.Concat(featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name))
);
| return ffKeys; | ||
| } | ||
|
|
||
| private static FlagSelector BuildFlagSelector(FeatureFlagSelector ffSelector) |
There was a problem hiding this comment.
This looks like a good candiate for an extension method on FeatureFlagSelector
| } | ||
|
|
||
| Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); | ||
| // Drop any classic feature flag in the mapped data that is now superseded by a |
There was a problem hiding this comment.
Even with the comment I'm having trouble understanding what this is for.
There was a problem hiding this comment.
Sorry about that, this loop is redundant. Will remove.
e798fae to
e2b5a0c
Compare
| SetData(await PrepareData(mappedData, cancellationToken).ConfigureAwait(false)); | ||
| List<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags | ||
| .Where(setting => !featureFlagKeys.Contains(setting.Key)) | ||
| .ToList(); |
There was a problem hiding this comment.
No need to allocate a list, keeping as an IEnumerable is fine.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 44 out of 44 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/ClassicFeatureFlagConverter.cs:33
- IsClassicFeatureFlag now requires a non-empty ContentType and only checks the parsed content type. This can regress classic feature flags that are stored under the ".appconfig.featureflag/" key namespace but lack (or have an unexpected) ContentType, and it also prevents ExcludeClassicFeatureFlags from reliably filtering those classic flags.
examples/ConfigStoreDemo/Program.cs:30 - The example now passes settings["connection_string"] directly into Connect(). When the value is missing/empty, this will throw an ArgumentNullException with a less actionable message than the previous explicit check.
var settings = config.AddJsonFile("appsettings.json").Build();
config.AddAzureAppConfiguration(options =>
{
options.Connect(settings["connection_string"])
.ConfigureRefresh(refresh =>
| if (ClientOptions.Transport != null) | ||
| { | ||
| FeatureFlagClientOptions.Transport = ClientOptions.Transport; | ||
| } |
| // Derive a deterministic ETag from the flag names + enabled state so that an unchanged | ||
| // collection keeps the same ETag and a changed collection produces a different one. | ||
| string content = string.Join("|", collection.Select(f => $"{f.Name}:{f.Enabled}")); | ||
|
|
||
| return "ff-" + content.GetHashCode().ToString("x8"); |
| IConfigurationSection endpointsSection = configuration.GetSection("AppConfig:Endpoints"); | ||
| IEnumerable<Uri> endpoints = endpointsSection.GetChildren() | ||
| .Select(endpoint => endpoint.Value) | ||
| .Where(value => !string.IsNullOrEmpty(value)) | ||
| .Select(value => new Uri(value)); | ||
| IEnumerable<Uri> endpoints = endpointsSection.GetChildren().Select(endpoint => new Uri(endpoint.Value)); | ||
|
|
| _options.FeatureFlagSelectors, | ||
| cancellationToken).ConfigureAwait(false); | ||
|
|
||
| ffKeys = new HashSet<string>( |
There was a problem hiding this comment.
ff keys should only need to hold classic feature flag keys. It's only used to remove classic feature flags from mapped data.
There was a problem hiding this comment.
Then _featureFlagIndex shouldn't be needed because it can always be obtained from _ffKeys.
There was a problem hiding this comment.
As a result, _ffKeys can be renamed to _classicFfKeys.
| // Avoid instance state modification | ||
| Dictionary<KeyValueSelector, IEnumerable<WatchedPage>> kvEtags = null; | ||
| Dictionary<KeyValueSelector, IEnumerable<WatchedPage>> ffEtags = null; | ||
| HashSet<string> ffKeys = null; |
There was a problem hiding this comment.
ffKeys can be declared after ExecuteWithFailoverPolicy. Since it doesn't rely on client, there's no need to initialize it inside that block.
| .Where(setting => !featureFlagKeys.Contains(setting.Key)); | ||
|
|
||
| // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously | ||
| foreach (string key in _ffKeys.Except(ffKeys)) |
There was a problem hiding this comment.
It seems we don't have to be too smart here. We can just remove all _ffKeys from _mappedData because we add them back right after.
Given we take the change for ff keys to only have classic feature flag keys.
|
|
||
| var pages = new Dictionary<FeatureFlagSelector, IEnumerable<WatchedPage>>(); | ||
|
|
||
| if (_options.ExcludeClassicFeatureFlags) |
There was a problem hiding this comment.
Keeping a single return statement makes things easier to review. I'd suggest keeping the single return statement at the bottom and putting the central piece in the if statement.
| /// ".appconfig.featureflag/") are not loaded from the configuration store. Only feature flags | ||
| /// returned by the new feature-flag endpoint will be loaded. | ||
| /// Defaults to false; classic flags are loaded alongside new flags for backward compatibility. | ||
| /// </summary> |
There was a problem hiding this comment.
No need for default value ( = false;) for booleans.
| var featureFlagKeys = new HashSet<string>( | ||
| featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name)); | ||
|
|
||
| IEnumerable<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags |
There was a problem hiding this comment.
| IEnumerable<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags | |
| IEnumerable<ConfigurationSetting> eligibleClassicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags |
| // Exclude any classic feature flags that are superseded by a standalone feature flag with the same name. | ||
| var featureFlagKeys = new HashSet<string>( | ||
| featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name)); | ||
|
|
||
| IEnumerable<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags | ||
| .Where(setting => !featureFlagKeys.Contains(setting.Key)); | ||
|
|
||
| // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously | ||
| foreach (string key in _ffKeys.Except(ffKeys)) | ||
| { | ||
| _mappedData.Remove(key); | ||
| } | ||
|
|
||
| Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); | ||
| // Remove any classic feature flags that are now superseded by a standalone feature flag with the same name. | ||
| foreach (FeatureFlag featureFlag in featureFlagLoadResult.FeatureFlags) | ||
| { | ||
| _mappedData.Remove(FeatureManagementConstants.FeatureFlagMarker + featureFlag.Name); | ||
| } | ||
|
|
||
| Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(classicFeatureFlags.ToDictionary(x => x.Key, x => x)).ConfigureAwait(false); | ||
|
|
||
| foreach (KeyValuePair<string, ConfigurationSetting> kvp in mappedFfData) | ||
| { | ||
| _mappedData[kvp.Key] = kvp.Value; | ||
| } | ||
|
|
||
| _featureFlags = featureFlagLoadResult.FeatureFlags; |
There was a problem hiding this comment.
| // Exclude any classic feature flags that are superseded by a standalone feature flag with the same name. | |
| var featureFlagKeys = new HashSet<string>( | |
| featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name)); | |
| IEnumerable<ConfigurationSetting> classicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags | |
| .Where(setting => !featureFlagKeys.Contains(setting.Key)); | |
| // Remove all feature flag keys that are not present in the latest loading of feature flags, but were loaded previously | |
| foreach (string key in _ffKeys.Except(ffKeys)) | |
| { | |
| _mappedData.Remove(key); | |
| } | |
| Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(ffCollectionData).ConfigureAwait(false); | |
| // Remove any classic feature flags that are now superseded by a standalone feature flag with the same name. | |
| foreach (FeatureFlag featureFlag in featureFlagLoadResult.FeatureFlags) | |
| { | |
| _mappedData.Remove(FeatureManagementConstants.FeatureFlagMarker + featureFlag.Name); | |
| } | |
| Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(classicFeatureFlags.ToDictionary(x => x.Key, x => x)).ConfigureAwait(false); | |
| foreach (KeyValuePair<string, ConfigurationSetting> kvp in mappedFfData) | |
| { | |
| _mappedData[kvp.Key] = kvp.Value; | |
| } | |
| _featureFlags = featureFlagLoadResult.FeatureFlags; | |
| // | |
| // Remove all previous classic feature flag keys | |
| // We'll will add them back below if they are still in the latest batch | |
| foreach (string key in _classicFfKeys) | |
| { | |
| _mappedData.Remove(key); | |
| } | |
| // | |
| // Exclude any classic feature flags that are superseded by a standalone feature flag with the same name. | |
| var ineligibleClassicFfKeys = new HashSet<string>( | |
| featureFlagLoadResult.FeatureFlags.Select(ff => FeatureManagementConstants.FeatureFlagMarker + ff.Name)); | |
| IEnumerable<ConfigurationSetting> eligibleClassicFeatureFlags = classicFeatureFlagLoadResult.ClassicFeatureFlags | |
| .Where(setting => !ineligibleClassicFfKeys.Contains(setting.Key)); | |
| Dictionary<string, ConfigurationSetting> mappedFfData = await MapConfigurationSettings(eligibleClassicFeatureFlags.ToDictionary(x => x.Key, x => x)).ConfigureAwait(false); | |
| foreach (KeyValuePair<string, ConfigurationSetting> kvp in mappedFfData) | |
| { | |
| _mappedData[kvp.Key] = kvp.Value; | |
| } | |
| _featureFlags = featureFlagLoadResult.FeatureFlags; |
Previously, feature flags were loaded only as classic key-values prefixed with
.appconfig.featureflag/. This PR registers a parallel "new feature flag" selector/watcher for each FeatureFlagSelector, fetches flags via the dedicated SDK endpoint, and synthesizes them back into the feature-management JSON schema so downstream parsing is unchanged. Classic flags can be turned off via a new opt-in flagExcludeClassicFeatureFlags.Changes: