Skip to content

Commit 06f23a2

Browse files
mrm9084Copilot
andauthored
App Config Provider - Feature Flag Page based Etags (#46412)
* Feature Flag Page based Etags * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fixed review comments * Adding check for more pages returning --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 74086a6 commit 06f23a2

10 files changed

Lines changed: 162 additions & 101 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
### Other Changes
1515

16+
- Switched feature flag refresh to use page-based etag checking instead of per-flag etag checking, reducing the number of requests needed to detect changes.
17+
1618
## 2.4.0 (2026-02-17)
1719

1820
### Features Added

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_b3a8244024"
5+
"Tag": "python/appconfiguration/azure-appconfiguration-provider_34a63910b7"
66
}

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

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f
114114
updated_watched_settings: Mapping[Tuple[str, str], Optional[str]] = {}
115115
existing_feature_flag_usage = self._tracing_context.feature_filter_usage.copy()
116116
page_etags: List[List[str]] = []
117+
feature_flag_page_etags: List[List[str]] = []
117118
try:
118119
if self._refresh_enabled and not self._watched_settings and self._refresh_timer.needs_refresh():
119120
configuration_refresh_attempted = True
@@ -140,12 +141,12 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f
140141
if self._feature_flag_refresh_enabled and self._feature_flag_refresh_timer.needs_refresh():
141142
feature_flag_refresh_attempted = True
142143

143-
feature_flags_need_refresh = client.try_check_feature_flags(
144-
self._watched_feature_flags, headers=headers, **kwargs
145-
)
146-
147-
if feature_flags_need_refresh:
148-
feature_flags = client.load_feature_flags(self._feature_flag_selectors, headers=headers, **kwargs)
144+
if not self._feature_flag_page_etags or client.check_feature_flag_page_etags(
145+
self._feature_flag_selectors, self._feature_flag_page_etags, headers=headers, **kwargs
146+
):
147+
feature_flags, feature_flag_page_etags = client.load_feature_flags(
148+
self._feature_flag_selectors, headers=headers, **kwargs
149+
)
149150

150151
# Default to existing settings if no refresh occurred
151152
processed_settings = self._dict
@@ -162,6 +163,8 @@ def _attempt_refresh(self, client: ConfigurationClient, replica_count: int, is_f
162163
self._page_etags = page_etags
163164
# Update the watch keys that have changed
164165
self._watched_settings.update(updated_watched_settings)
166+
if feature_flags is not None:
167+
self._feature_flag_page_etags = feature_flag_page_etags
165168
# Reset timers at the same time as they should load from the same store.
166169
if configuration_refresh_attempted:
167170
self._refresh_timer.reset()
@@ -274,8 +277,10 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) ->
274277
watched_settings = self._update_watched_settings(configuration_settings)
275278
processed_settings = self._process_configurations(configuration_settings, client)
276279

280+
feature_flag_page_etags: List[List[str]] = []
277281
if self._feature_flag_enabled:
278-
feature_flags: List[FeatureFlagConfigurationSetting] = client.load_feature_flags(
282+
feature_flags: List[FeatureFlagConfigurationSetting]
283+
feature_flags, feature_flag_page_etags = client.load_feature_flags(
279284
self._feature_flag_selectors,
280285
headers=headers,
281286
**kwargs,
@@ -304,6 +309,7 @@ def _try_initialize(self, startup_exceptions: List[Exception], **kwargs: Any) ->
304309
self._watched_settings = watched_settings
305310
self._dict = processed_settings
306311
self._page_etags = page_etags
312+
self._feature_flag_page_etags = feature_flag_page_etags
307313
return True
308314
except AzureError as e:
309315
logger.warning("Failed to load configurations from endpoint %s.\n %s", client.endpoint, e.message)
@@ -371,9 +377,6 @@ def _process_configurations(
371377
# Feature flags are not processed like other settings
372378
feature_flag_value = self._process_feature_flag(setting)
373379
feature_flags_processed.append(feature_flag_value)
374-
375-
if self._feature_flag_refresh_enabled:
376-
self._watched_feature_flags[(setting.key, setting.label)] = setting.etag
377380
else:
378381
key = self._process_key_name(setting)
379382
value = self._process_key_value(setting)

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

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ def __init__(self, **kwargs: Any) -> None:
105105
self._feature_flag_selectors = kwargs.pop("feature_flag_selectors", None)
106106
if self._feature_flag_selectors is None:
107107
self._feature_flag_selectors = [SettingSelector(key_filter="*")]
108-
self._watched_feature_flags: Dict[Tuple[str, str], Optional[str]] = {}
109108
self._feature_flag_refresh_timer: _RefreshTimer = _RefreshTimer(**kwargs)
110109
self._feature_flag_refresh_enabled = kwargs.pop("feature_flag_refresh_enabled", False)
111110
refresh_enabled = kwargs.pop("refresh_enabled", None)
@@ -115,6 +114,7 @@ def __init__(self, **kwargs: Any) -> None:
115114
refresh_enabled = True
116115
self._refresh_enabled = refresh_enabled
117116
self._page_etags: List[List[str]] = []
117+
self._feature_flag_page_etags: List[List[str]] = []
118118
self._tracing_context = _RequestTracingContext(kwargs.pop("load_balancing_enabled", False))
119119
self._update_lock = Lock()
120120
self._refresh_lock = Lock()
@@ -374,7 +374,6 @@ def _process_feature_flags(
374374
# Reset feature flag usage
375375
self._tracing_context.reset_feature_filter_usage()
376376
processed_feature_flags = [self._process_feature_flag(ff) for ff in feature_flags]
377-
self._watched_feature_flags = self._update_watched_feature_flags(feature_flags)
378377

379378
if self._feature_flag_enabled:
380379
processed_settings[FEATURE_MANAGEMENT_KEY] = {}
@@ -406,20 +405,6 @@ def _update_watched_settings(
406405
watched_settings[(config.key, config.label)] = config.etag
407406
return watched_settings
408407

409-
def _update_watched_feature_flags(
410-
self, feature_flags: List[FeatureFlagConfigurationSetting]
411-
) -> Dict[Tuple[str, str], Optional[str]]:
412-
"""
413-
Updates the etags of watched feature flags that are part of the configuration
414-
:param List[FeatureFlagConfigurationSetting] feature_flags: The list of feature flags to update
415-
:return: A dictionary mapping (key, label) tuples to their updated etags
416-
:rtype: Dict[Tuple[str, str], Optional[str]]
417-
"""
418-
watched_feature_flags: Dict[Tuple[str, str], Optional[str]] = {}
419-
for feature_flag in feature_flags:
420-
watched_feature_flags[(feature_flag.key, feature_flag.label)] = feature_flag.etag
421-
return watched_feature_flags
422-
423408
def _update_correlation_context_header(
424409
self,
425410
headers: Dict[str, str],

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

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,9 @@ def check_page_etags(self, selects: List[SettingSelector], page_etags: List[List
187187
:rtype: bool
188188
"""
189189
for i, select in enumerate(selects):
190+
if i >= len(page_etags):
191+
# Missing or stale etag state should trigger a refresh instead of failing.
192+
return True
190193
selector_etags = page_etags[i]
191194
if select.snapshot_name is None:
192195
# We only process non-snapshot selectors here, because snapshot never change
@@ -204,17 +207,30 @@ def check_page_etags(self, selects: List[SettingSelector], page_etags: List[List
204207
@distributed_trace
205208
def load_feature_flags(
206209
self, feature_flag_selectors: List[SettingSelector], **kwargs
207-
) -> List[FeatureFlagConfigurationSetting]:
210+
) -> Tuple[List[FeatureFlagConfigurationSetting], List[List[str]]]:
211+
"""
212+
Loads feature flags using page-based iteration, collecting page etags for each selector.
213+
214+
:param feature_flag_selectors: List of setting selectors to filter feature flags
215+
:type feature_flag_selectors: List[SettingSelector]
216+
:return: A tuple of (feature_flags, page_etags_per_selector)
217+
:rtype: Tuple[List[FeatureFlagConfigurationSetting], List[List[str]]]
218+
"""
208219
loaded_feature_flags: List[FeatureFlagConfigurationSetting] = []
220+
page_etags: List[List[str]] = []
209221
# Needs to be removed unknown keyword argument for list_configuration_settings
210222
kwargs.pop("sentinel_keys", None)
211223
for select in feature_flag_selectors:
212-
feature_flags = []
224+
selector_etags: List[str] = []
213225
if select.snapshot_name is not None:
214226
# When loading from a snapshot, ignore key_filter, label_filter, and tag_filters
215227
if not self._validate_snapshot(select.snapshot_name):
216-
return []
228+
page_etags.append(selector_etags)
229+
continue
217230
feature_flags = self._client.list_configuration_settings(snapshot_name=select.snapshot_name, **kwargs)
231+
for ff in feature_flags:
232+
if isinstance(ff, FeatureFlagConfigurationSetting):
233+
loaded_feature_flags.append(ff)
218234
else:
219235
# Handle None key_filter by converting to empty string
220236
key_filter = select.key_filter if select.key_filter is not None else ""
@@ -224,9 +240,47 @@ def load_feature_flags(
224240
tags_filter=select.tag_filters,
225241
**kwargs,
226242
)
227-
loaded_feature_flags.extend(ff for ff in feature_flags if isinstance(ff, FeatureFlagConfigurationSetting))
243+
iterator = feature_flags.by_page()
244+
for page in iterator:
245+
for ff in page:
246+
if isinstance(ff, FeatureFlagConfigurationSetting):
247+
loaded_feature_flags.append(ff)
248+
selector_etags.append(iterator.etag)
249+
page_etags.append(selector_etags)
228250

229-
return loaded_feature_flags
251+
return loaded_feature_flags, page_etags
252+
253+
@distributed_trace
254+
def check_feature_flag_page_etags(
255+
self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs
256+
) -> bool:
257+
"""
258+
Checks if any feature flag page has changed using page etags.
259+
260+
:param feature_flag_selectors: List of setting selectors for feature flags
261+
:type feature_flag_selectors: List[SettingSelector]
262+
:param page_etags: The page etags from the last load, one list per selector
263+
:type page_etags: List[List[str]]
264+
:return: True if any page has changed, False otherwise
265+
:rtype: bool
266+
"""
267+
for i, select in enumerate(feature_flag_selectors):
268+
if i >= len(page_etags):
269+
# Missing or stale etag state should trigger a refresh instead of failing.
270+
return True
271+
selector_etags = page_etags[i]
272+
if select.snapshot_name is None:
273+
key_filter = select.key_filter if select.key_filter is not None else ""
274+
feature_flags = self._client.list_configuration_settings(
275+
key_filter=FEATURE_FLAG_PREFIX + key_filter,
276+
label_filter=select.label_filter,
277+
tags_filter=select.tag_filters,
278+
**kwargs,
279+
)
280+
for _ in feature_flags.by_page(match_conditions=selector_etags):
281+
# If any page is returned, it means that page has changed
282+
return True
283+
return False
230284

231285
@distributed_trace
232286
def get_updated_watched_settings(
@@ -258,25 +312,6 @@ def get_updated_watched_settings(
258312
return updated_watched_settings
259313
return {}
260314

261-
@distributed_trace
262-
def try_check_feature_flags(
263-
self, watched_feature_flags: Mapping[Tuple[str, str], Optional[str]], headers: Dict[str, str], **kwargs
264-
) -> bool:
265-
"""
266-
Gets the refreshed feature flags if they have changed.
267-
268-
:param Mapping[Tuple[str, str], Optional[str]] watched_feature_flags: The feature flags to check for changes
269-
:param Mapping[str, str] headers: The headers to use for the request
270-
271-
:return: True if any feature flags have changed, False otherwise
272-
:rtype: bool
273-
"""
274-
for (key, label), etag in watched_feature_flags.items():
275-
changed, _ = self._check_configuration_setting(key=key, label=label, etag=etag, headers=headers, **kwargs)
276-
if changed:
277-
return True
278-
return False
279-
280315
@distributed_trace
281316
def get_configuration_setting(self, key: str, label: str, **kwargs) -> Optional[ConfigurationSetting]:
282317
"""

sdk/appconfiguration/azure-appconfiguration-provider/azure/appconfiguration/provider/aio/_async_client_manager.py

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from typing import Tuple, Union, Dict, List, Optional, Mapping, TYPE_CHECKING
1111
from typing_extensions import Self
1212
from azure.core import MatchConditions
13-
from azure.core.async_paging import AsyncItemPaged
1413
from azure.core.tracing.decorator import distributed_trace
1514
from azure.core.exceptions import HttpResponseError
1615
from azure.appconfiguration import ( # type:ignore # pylint:disable=no-name-in-module
@@ -190,6 +189,9 @@ async def check_page_etags(self, selects: List[SettingSelector], page_etags: Lis
190189
:rtype: bool
191190
"""
192191
for i, select in enumerate(selects):
192+
if i >= len(page_etags):
193+
# Missing or stale etag state should trigger a refresh instead of failing.
194+
return True
193195
selector_etags = page_etags[i]
194196
if select.snapshot_name is None:
195197
# We only process non-snapshot selectors here, because snapshot never change
@@ -207,17 +209,30 @@ async def check_page_etags(self, selects: List[SettingSelector], page_etags: Lis
207209
@distributed_trace
208210
async def load_feature_flags(
209211
self, feature_flag_selectors: List[SettingSelector], **kwargs
210-
) -> List[FeatureFlagConfigurationSetting]:
212+
) -> Tuple[List[FeatureFlagConfigurationSetting], List[List[str]]]:
213+
"""
214+
Loads feature flags using page-based iteration, collecting page etags for each selector.
215+
216+
:param feature_flag_selectors: List of setting selectors to filter feature flags
217+
:type feature_flag_selectors: List[SettingSelector]
218+
:return: A tuple of (feature_flags, page_etags_per_selector)
219+
:rtype: Tuple[List[FeatureFlagConfigurationSetting], List[List[str]]]
220+
"""
211221
loaded_feature_flags: List[FeatureFlagConfigurationSetting] = []
222+
page_etags: List[List[str]] = []
212223
# Needs to be removed unknown keyword argument for list_configuration_settings
213224
kwargs.pop("sentinel_keys", None)
214225
for select in feature_flag_selectors:
215-
feature_flags: AsyncItemPaged[ConfigurationSetting]
226+
selector_etags: List[str] = []
216227
if select.snapshot_name is not None:
217228
# When loading from a snapshot, ignore key_filter, label_filter, and tag_filters
218229
if not await self._validate_snapshot(select.snapshot_name):
219-
return []
230+
page_etags.append(selector_etags)
231+
continue
220232
feature_flags = self._client.list_configuration_settings(snapshot_name=select.snapshot_name, **kwargs)
233+
async for ff in feature_flags:
234+
if isinstance(ff, FeatureFlagConfigurationSetting):
235+
loaded_feature_flags.append(ff)
221236
else:
222237
# Handle None key_filter by converting to empty string
223238
key_filter = select.key_filter if select.key_filter is not None else ""
@@ -227,11 +242,47 @@ async def load_feature_flags(
227242
tags_filter=select.tag_filters,
228243
**kwargs,
229244
)
230-
loaded_feature_flags.extend(
231-
[ff async for ff in feature_flags if isinstance(ff, FeatureFlagConfigurationSetting)]
232-
)
245+
iterator = feature_flags.by_page()
246+
async for page in iterator:
247+
async for ff in page:
248+
if isinstance(ff, FeatureFlagConfigurationSetting):
249+
loaded_feature_flags.append(ff)
250+
selector_etags.append(iterator.etag) # type: ignore[attr-defined]
251+
page_etags.append(selector_etags)
252+
253+
return loaded_feature_flags, page_etags
233254

234-
return loaded_feature_flags
255+
@distributed_trace
256+
async def check_feature_flag_page_etags(
257+
self, feature_flag_selectors: List[SettingSelector], page_etags: List[List[str]], **kwargs
258+
) -> bool:
259+
"""
260+
Checks if any feature flag page has changed using page etags.
261+
262+
:param feature_flag_selectors: List of setting selectors for feature flags
263+
:type feature_flag_selectors: List[SettingSelector]
264+
:param page_etags: The page etags from the last load, one list per selector
265+
:type page_etags: List[List[str]]
266+
:return: True if any page has changed, False otherwise
267+
:rtype: bool
268+
"""
269+
for i, select in enumerate(feature_flag_selectors):
270+
if i >= len(page_etags):
271+
# Missing or stale etag state should trigger a refresh instead of failing.
272+
return True
273+
selector_etags = page_etags[i]
274+
if select.snapshot_name is None:
275+
key_filter = select.key_filter if select.key_filter is not None else ""
276+
feature_flags = self._client.list_configuration_settings(
277+
key_filter=FEATURE_FLAG_PREFIX + key_filter,
278+
label_filter=select.label_filter,
279+
tags_filter=select.tag_filters,
280+
**kwargs,
281+
)
282+
async for _ in feature_flags.by_page(match_conditions=selector_etags): # type: ignore[call-arg]
283+
# If any page is returned, it means that page has changed
284+
return True
285+
return False
235286

236287
@distributed_trace
237288
async def get_updated_watched_settings(
@@ -263,27 +314,6 @@ async def get_updated_watched_settings(
263314
return updated_watched_settings
264315
return {}
265316

266-
@distributed_trace
267-
async def try_check_feature_flags(
268-
self, watched_feature_flags: Mapping[Tuple[str, str], Optional[str]], headers: Dict[str, str], **kwargs
269-
) -> bool:
270-
"""
271-
Gets the refreshed feature flags if they have changed.
272-
273-
:param Mapping[Tuple[str, str], Optional[str]] watched_feature_flags: The feature flags to check for changes
274-
:param Mapping[str, str] headers: The headers to use for the request
275-
276-
:return: True if any feature flags have changed, False otherwise
277-
:rtype: bool
278-
"""
279-
for (key, label), etag in watched_feature_flags.items():
280-
changed, _ = await self._check_configuration_setting(
281-
key=key, label=label, etag=etag, headers=headers, **kwargs
282-
)
283-
if changed:
284-
return True
285-
return False
286-
287317
@distributed_trace
288318
async def get_configuration_setting(self, key: str, label: str, **kwargs) -> Optional[ConfigurationSetting]:
289319
"""

0 commit comments

Comments
 (0)