Skip to content

Commit 0cb2dec

Browse files
authored
App Configuration - Feature flag refresh (#33693)
* Updating feature flag usage * For some reason this isn't popped in async * Updadating Tests * Update CHANGELOG.md * Formatting * Fixing Refresh, Sync * Updating Schema 2.0.0 * Update test_async_provider.py * fixed refresh and merge * fixing mypy * fixing pylint issues * Updated from review comments * Update README.md * Update README.md * Fixed pylint issue * Update assets.json * formatting * pylint and test fix * Updating Min SDK version to bug in 1.4.0 with feature flags * Update setup.py * Updating so configs and feature flags don't refresh each other * Added missing _ and tests * Update assets.json * Fixing MyPy issue
1 parent 83a23eb commit 0cb2dec

18 files changed

+618
-144
lines changed

sdk/appconfiguration/azure-appconfiguration-provider/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44

55
### Features Added
66

7+
* Enable loading of feature flags with `feature_flag_enabled`
8+
* Select Feature Flags to load with `feature_flag_selectors`
9+
* Enable/Disable Feature Flag Refresh with `feature_flag_refresh_enabled`
10+
711
### Breaking Changes
812

913
### Bugs Fixed

sdk/appconfiguration/azure-appconfiguration-provider/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,33 @@ key_vault_options = AzureAppConfigurationKeyVaultOptions(
168168
config = load(endpoint=endpoint, credential=DefaultAzureCredential(), key_vault_options=key_vault_options)
169169
```
170170

171+
## Loading Feature Flags
172+
173+
Feature Flags can be loaded from config stores using the provider. Feature flags are loaded as a dictionary of key/value pairs stored in the provider under the `FeatureManagement`, then `FeatureFlags`.
174+
175+
```python
176+
config = load(endpoint=endpoint, credential=DefaultAzureCredential(), feature_flags_enabled=True)
177+
alpha = config["FeatureManagement"]["FeatureFlags"]["Alpha"]
178+
print(alpha["enabled"])
179+
```
180+
181+
By default all feature flags with no label are loaded. If you want to load feature flags with a specific label you can use `SettingSelector` to filter the feature flags.
182+
183+
```python
184+
from azure.appconfiguration.provider import load, SettingSelector
185+
186+
config = load(endpoint=endpoint, credential=DefaultAzureCredential(), feature_flags_enabled=True, feature_flag_selectors=[SettingSelector(key_filter="*", label_filter="dev")])
187+
alpha = config["FeatureManagement"]["FeatureFlags"]["Alpha"]
188+
print(alpha["enabled"])
189+
```
190+
191+
To enable refresh for feature flags you need to enable refresh. This will allow the provider to refresh feature flags the same way it refreshes configurations. Unlike configurations, all loaded feature flags are monitored for changes and will cause a refresh. Refresh of configuration settings and feature flags are independent of each other. Both are trigged by the `refresh` method, but a feature flag changing will not cause a refresh of configurations and vice versa. Also, if refresh for configuration settings is not enabled, feature flags can still be enabled for refresh.
192+
193+
```python
194+
config = load(endpoint=endpoint, credential=DefaultAzureCredential(), feature_flags_enabled=True, feature_flag_refresh_enabled=True)
195+
config.refresh()
196+
```
197+
171198
## Key concepts
172199

173200
## Troubleshooting

sdk/appconfiguration/azure-appconfiguration-provider/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "python",
44
"TagPrefix": "python/appconfiguration/azure-appconfiguration-provider",
5-
"Tag": "python/appconfiguration/azure-appconfiguration-provider_51fb3fb738"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_8a49e8ba1e"
66
}

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_azureappconfigurationprovider.py

Lines changed: 141 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,15 @@
3232
AzureAppConfigurationClient,
3333
FeatureFlagConfigurationSetting,
3434
SecretReferenceConfigurationSetting,
35+
ConfigurationSetting,
3536
)
3637
from azure.core import MatchConditions
3738
from azure.core.exceptions import HttpResponseError, ServiceRequestError, ServiceResponseError
3839
from azure.keyvault.secrets import SecretClient, KeyVaultSecretIdentifier
3940
from ._models import AzureAppConfigurationKeyVaultOptions, SettingSelector
4041
from ._constants import (
4142
FEATURE_MANAGEMENT_KEY,
43+
FEATURE_FLAG_KEY,
4244
FEATURE_FLAG_PREFIX,
4345
REQUEST_TRACING_DISABLED_ENVIRONMENT_VARIABLE,
4446
ServiceFabricEnvironmentVariable,
@@ -107,6 +109,14 @@ def load(
107109
:paramtype on_refresh_error: Optional[Callable[[Exception], None]]
108110
:keyword on_refresh_error: Optional callback to be invoked when an error occurs while refreshing settings. If not
109111
specified, errors will be raised.
112+
:paramtype feature_flag_enabled: bool
113+
:keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False.
114+
:paramtype feature_flag_selectors: List[SettingSelector]
115+
:keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all
116+
feature flags without a label.
117+
:paramtype feature_flag_refresh_enabled: bool
118+
:keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is
119+
False.
110120
"""
111121

112122

@@ -154,6 +164,14 @@ def load(
154164
:paramtype on_refresh_error: Optional[Callable[[Exception], None]]
155165
:keyword on_refresh_error: Optional callback to be invoked when an error occurs while refreshing settings. If not
156166
specified, errors will be raised.
167+
:paramtype feature_flag_enabled: bool
168+
:keyword feature_flag_enabled: Optional flag to enable or disable the loading of feature flags. Default is False.
169+
:paramtype feature_flag_selectors: List[SettingSelector]
170+
:keyword feature_flag_selectors: Optional list of selectors to filter feature flags. By default will load all
171+
feature flags without a label.
172+
:paramtype feature_flag_refresh_enabled: bool
173+
:keyword feature_flag_refresh_enabled: Optional flag to enable or disable the refresh of feature flags. Default is
174+
False.
157175
"""
158176

159177

@@ -201,6 +219,7 @@ def load(*args, **kwargs) -> "AzureAppConfigurationProvider":
201219
provider = _buildprovider(
202220
connection_string, endpoint, credential, uses_key_vault="UsesKeyVault" in headers, **kwargs
203221
)
222+
204223
try:
205224
provider._load_all(headers=headers)
206225
except Exception as e:
@@ -221,6 +240,7 @@ def load(*args, **kwargs) -> "AzureAppConfigurationProvider":
221240
key,
222241
label,
223242
)
243+
provider._refresh_on[(key, label)] = None # type: ignore
224244
else:
225245
_delay_failure(start_time)
226246
raise e
@@ -453,12 +473,17 @@ def __init__(self, **kwargs) -> None:
453473
or self._keyvault_client_configs is not None
454474
or self._secret_resolver is not None
455475
)
476+
self._feature_flag_enabled = kwargs.pop("feature_flag_enabled", False)
477+
self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", [SettingSelector(key_filter="*")])
478+
self._refresh_on_feature_flags: Mapping[Tuple[str, str], Optional[str]] = {}
479+
self._feature_flag_refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs)
480+
self._feature_flag_refresh_enabled = kwargs.pop("feature_flag_refresh_enabled", False)
456481
self._update_lock = Lock()
457482
self._refresh_lock = Lock()
458483

459484
def refresh(self, **kwargs) -> None:
460-
if not self._refresh_on:
461-
logging.debug("Refresh called but no refresh options set.")
485+
if not self._refresh_on and not self._feature_flag_refresh_enabled:
486+
logging.debug("Refresh called but no refresh enabled.")
462487
return
463488
if not self._refresh_timer.needs_refresh():
464489
logging.debug("Refresh called but refresh interval not elapsed.")
@@ -469,39 +494,10 @@ def refresh(self, **kwargs) -> None:
469494
success = False
470495
need_refresh = False
471496
try:
472-
updated_sentinel_keys = dict(self._refresh_on)
473-
headers = _get_headers("Watch", uses_key_vault=self._uses_key_vault, **kwargs)
474-
for (key, label), etag in updated_sentinel_keys.items():
475-
try:
476-
updated_sentinel = self._client.get_configuration_setting( # type:ignore
477-
key=key,
478-
label=label,
479-
etag=etag,
480-
match_condition=MatchConditions.IfModified,
481-
headers=headers,
482-
**kwargs
483-
)
484-
if updated_sentinel is not None:
485-
logging.debug(
486-
"Refresh all triggered by key: %s label %s.",
487-
key,
488-
label,
489-
)
490-
need_refresh = True
491-
492-
updated_sentinel_keys[(key, label)] = updated_sentinel.etag
493-
except HttpResponseError as e:
494-
if e.status_code == 404:
495-
if etag is not None:
496-
# If the sentinel is not found, it means the key/label was deleted, so we should refresh
497-
logging.debug("Refresh all triggered by key: %s label %s.", key, label)
498-
need_refresh = True
499-
updated_sentinel_keys[(key, label)] = None
500-
else:
501-
raise e
502-
# Need to only update once, no matter how many sentinels are updated
503-
if need_refresh:
504-
self._load_all(headers=headers, sentinel_keys=updated_sentinel_keys, **kwargs)
497+
if self._refresh_on:
498+
need_refresh = self._refresh_configuration_settings(**kwargs)
499+
if self._feature_flag_refresh_enabled:
500+
need_refresh = self._refresh_feature_flags(**kwargs) or need_refresh
505501
# Even if we don't need to refresh, we should reset the timer
506502
self._refresh_timer.reset()
507503
success = True
@@ -518,31 +514,132 @@ def refresh(self, **kwargs) -> None:
518514
elif need_refresh and self._on_refresh_success:
519515
self._on_refresh_success()
520516

517+
def _refresh_configuration_settings(self, **kwargs) -> bool:
518+
need_refresh = False
519+
updated_sentinel_keys = dict(self._refresh_on)
520+
headers = _get_headers("Watch", uses_key_vault=self._uses_key_vault, **kwargs)
521+
for (key, label), etag in updated_sentinel_keys.items():
522+
changed, updated_sentinel = self._check_configuration_setting(
523+
key=key, label=label, etag=etag, headers=headers, **kwargs
524+
)
525+
if changed:
526+
need_refresh = True
527+
if updated_sentinel is not None:
528+
updated_sentinel_keys[(key, label)] = updated_sentinel.etag
529+
# Need to only update once, no matter how many sentinels are updated
530+
if need_refresh:
531+
configuration_settings, sentinel_keys = self._load_configuration_settings(**kwargs)
532+
if self._feature_flag_enabled:
533+
configuration_settings[FEATURE_MANAGEMENT_KEY] = self._dict[FEATURE_MANAGEMENT_KEY]
534+
with self._update_lock:
535+
self._refresh_on = sentinel_keys
536+
self._dict = configuration_settings
537+
return need_refresh
538+
539+
def _refresh_feature_flags(self, **kwargs) -> bool:
540+
feature_flag_sentinel_keys = dict(self._refresh_on_feature_flags)
541+
headers = _get_headers("Watch", uses_key_vault=self._uses_key_vault, **kwargs)
542+
for (key, label), etag in feature_flag_sentinel_keys.items():
543+
changed = self._check_configuration_setting(key=key, label=label, etag=etag, headers=headers, **kwargs)
544+
if changed:
545+
feature_flags, feature_flag_sentinel_keys = self._load_feature_flags(**kwargs)
546+
with self._update_lock:
547+
updated_configurations: Dict[str, Any] = {}
548+
updated_configurations[FEATURE_MANAGEMENT_KEY] = {}
549+
updated_configurations[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY] = feature_flags
550+
self._dict.update(updated_configurations)
551+
self._refresh_on_feature_flags = feature_flag_sentinel_keys
552+
return True
553+
return False
554+
555+
def _check_configuration_setting(
556+
self, key, label, etag, headers, **kwargs
557+
) -> Tuple[bool, Union[ConfigurationSetting, None]]:
558+
"""
559+
Checks if the configuration setting have been updated since the last refresh.
560+
561+
:keyword key: key to check for chances
562+
:paramtype key: str
563+
:keyword label: label to check for changes
564+
:paramtype label: str
565+
:keyword etag: etag to check for changes
566+
:paramtype etag: str
567+
:keyword headers: headers to use for the request
568+
:paramtype headers: Mapping[str, str]
569+
:return: A tuple with the first item being true/false if a change is detected. The second item is the updated
570+
value if a change was detected.
571+
:rtype: Tuple[bool, Union[ConfigurationSetting, None]]
572+
"""
573+
try:
574+
updated_sentinel = self._client.get_configuration_setting( # type: ignore
575+
key=key, label=label, etag=etag, match_condition=MatchConditions.IfModified, headers=headers, **kwargs
576+
)
577+
if updated_sentinel is not None:
578+
logging.debug(
579+
"Refresh all triggered by key: %s label %s.",
580+
key,
581+
label,
582+
)
583+
return True, updated_sentinel
584+
except HttpResponseError as e:
585+
if e.status_code == 404:
586+
if etag is not None:
587+
# If the sentinel is not found, it means the key/label was deleted, so we should refresh
588+
logging.debug("Refresh all triggered by key: %s label %s.", key, label)
589+
return True, None
590+
else:
591+
raise e
592+
return False, None
593+
521594
def _load_all(self, **kwargs):
595+
configuration_settings, sentinel_keys = self._load_configuration_settings(**kwargs)
596+
if self._feature_flag_enabled:
597+
feature_flags, feature_flag_sentinel_keys = self._load_feature_flags(**kwargs)
598+
configuration_settings[FEATURE_MANAGEMENT_KEY] = {}
599+
configuration_settings[FEATURE_MANAGEMENT_KEY][FEATURE_FLAG_KEY] = feature_flags
600+
self._refresh_on_feature_flags = feature_flag_sentinel_keys
601+
with self._update_lock:
602+
self._refresh_on = sentinel_keys
603+
self._dict = configuration_settings
604+
605+
def _load_configuration_settings(self, **kwargs):
522606
configuration_settings = {}
523607
sentinel_keys = kwargs.pop("sentinel_keys", self._refresh_on)
524608
for select in self._selects:
525609
configurations = self._client.list_configuration_settings(
526610
key_filter=select.key_filter, label_filter=select.label_filter, **kwargs
527611
)
528612
for config in configurations:
529-
key = self._process_key_name(config)
530-
value = self._process_key_value(config)
531613
if isinstance(config, FeatureFlagConfigurationSetting):
532-
feature_management = configuration_settings.get(FEATURE_MANAGEMENT_KEY, {})
533-
feature_management[key] = value
534-
if FEATURE_MANAGEMENT_KEY not in configuration_settings:
535-
configuration_settings[FEATURE_MANAGEMENT_KEY] = feature_management
614+
# Feature flags are ignored when loaded by Selects, as they are selected from
615+
# `feature_flag_selectors`
616+
pass
536617
else:
618+
key = self._process_key_name(config)
619+
value = self._process_key_value(config)
537620
configuration_settings[key] = value
538621
# Every time we run load_all, we should update the etag of our refresh sentinels
539622
# so they stay up-to-date.
540623
# Sentinel keys will have unprocessed key names, so we need to use the original key.
541624
if (config.key, config.label) in self._refresh_on:
542625
sentinel_keys[(config.key, config.label)] = config.etag
543-
self._refresh_on = sentinel_keys
544-
with self._update_lock:
545-
self._dict = configuration_settings
626+
return configuration_settings, sentinel_keys
627+
628+
def _load_feature_flags(self, **kwargs):
629+
feature_flag_sentinel_keys = {}
630+
loaded_feature_flags = []
631+
# Needs to be removed unknown keyword argument for list_configuration_settings
632+
kwargs.pop("sentinel_keys", None)
633+
for select in self._feature_flag_selectors:
634+
feature_flags = self._client.list_configuration_settings(
635+
key_filter=FEATURE_FLAG_PREFIX + select.key_filter, label_filter=select.label_filter, **kwargs
636+
)
637+
for feature_flag in feature_flags:
638+
loaded_feature_flags.append(json.loads(feature_flag.value))
639+
640+
if self._feature_flag_refresh_enabled:
641+
feature_flag_sentinel_keys[(feature_flag.key, feature_flag.label)] = feature_flag.etag
642+
return loaded_feature_flags, feature_flag_sentinel_keys
546643

547644
def _process_key_name(self, config):
548645
trimmed_key = config.key
@@ -551,8 +648,6 @@ def _process_key_name(self, config):
551648
if config.key.startswith(trim):
552649
trimmed_key = config.key[len(trim) :]
553650
break
554-
if isinstance(config, FeatureFlagConfigurationSetting) and trimmed_key.startswith(FEATURE_FLAG_PREFIX):
555-
return trimmed_key[len(FEATURE_FLAG_PREFIX) :]
556651
return trimmed_key
557652

558653
def _process_key_value(self, config):

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/_constants.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
# license information.
55
# -------------------------------------------------------------------------
66

7-
FEATURE_MANAGEMENT_KEY = "FeatureManagementFeatureFlags"
7+
FEATURE_MANAGEMENT_KEY = "FeatureManagement"
8+
FEATURE_FLAG_KEY = "FeatureFlags"
89
FEATURE_FLAG_PREFIX = ".appconfig.featureflag/"
910

1011
EMPTY_LABEL = "\0"

0 commit comments

Comments
 (0)