From 8ebf52eefa381b7782de3cb358127a5fb576d0dc Mon Sep 17 00:00:00 2001
From: Copilot <198982749+copilot@users.noreply.github.com>
Date: Tue, 30 Dec 2025 13:43:21 +0800
Subject: [PATCH 01/22] Fix: Skip App Configuration validation when feature is
disabled (#47588)
(cherry picked from commit 0d08d06a5b1e6e35b6d7fbd9ec3aa9117605353f)
---
sdk/spring/CHANGELOG.md | 8 +++++
.../AzureAppConfigDataLocationResolver.java | 10 +++++-
...zureAppConfigDataLocationResolverTest.java | 35 +++++++++++++++++++
3 files changed, 52 insertions(+), 1 deletion(-)
diff --git a/sdk/spring/CHANGELOG.md b/sdk/spring/CHANGELOG.md
index 206775073ed1..3ee4233e8ead 100644
--- a/sdk/spring/CHANGELOG.md
+++ b/sdk/spring/CHANGELOG.md
@@ -1,4 +1,12 @@
# Release History
+## 6.3.0
+
+### Spring Cloud Azure Appconfiguration Config
+This section includes changes in `spring-cloud-azure-appconfiguration-config` module.
+
+#### Bugs Fixed
+
+- Fixed bug where connection string validation occurred even when `spring.cloud.azure.appconfiguration.enabled` is `false`. ([#47587](https://github.com/Azure/azure-sdk-for-java/issues/47587))
## 6.2.0 (2026-03-25)
- This release is compatible with Spring Boot 3.5.0-3.5.8. (Note: 3.5.x (x>8) should be supported, but they aren't tested with this release.)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java
index b4ecd1b477ba..6a0ebe649ad1 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java
@@ -57,8 +57,16 @@ public boolean isResolvable(ConfigDataLocationResolverContext context, ConfigDat
return false;
}
+ Binder binder = context.getBinder();
+
+ // Check if Azure App Configuration is enabled
+ Boolean enabled = binder.bind(AppConfigurationProperties.CONFIG_PREFIX + ".enabled", Boolean.class).orElse(true);
+ if (!enabled) {
+ return false;
+ }
+
// Check if the configuration properties for Azure App Configuration are present
- return hasValidStoreConfiguration(context.getBinder());
+ return hasValidStoreConfiguration(binder);
}
/**
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolverTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolverTest.java
index 04cc61b72850..a3f3655b20c7 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolverTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolverTest.java
@@ -47,6 +47,12 @@ class AzureAppConfigDataLocationResolverTest {
@Mock
BindResult validResult;
+ @Mock
+ BindResult enabledTrueResult;
+
+ @Mock
+ BindResult enabledFalseResult;
+
private static final String PREFIX = "azureAppConfiguration:";
private static final String INVALID_PREFIX = "someOtherPrefix:";
@@ -80,6 +86,9 @@ void testIsResolvableWithCorrectPrefix() {
ConfigDataLocation location = ConfigDataLocation.of(PREFIX);
when(mockContext.getBinder()).thenReturn(mockBinder);
+ when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class))
+ .thenReturn(enabledTrueResult);
+ when(enabledTrueResult.orElse(true)).thenReturn(true);
when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class))
.thenReturn(validResult);
when(validResult.orElse("")).thenReturn("https://test.config.io");
@@ -95,6 +104,9 @@ void testIsResolvableWithValidConnectionStringConfiguration() {
ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:");
when(mockContext.getBinder()).thenReturn(mockBinder);
+ when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class))
+ .thenReturn(enabledTrueResult);
+ when(enabledTrueResult.orElse(true)).thenReturn(true);
when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class))
.thenReturn(emptyResult);
when(emptyResult.orElse("")).thenReturn("");
@@ -108,11 +120,28 @@ void testIsResolvableWithValidConnectionStringConfiguration() {
assertTrue(result, "Resolver should accept locations with valid connection-string configuration");
}
+ @Test
+ void testIsResolvableWhenAppConfigurationIsDisabled() {
+ ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:");
+
+ when(mockContext.getBinder()).thenReturn(mockBinder);
+ when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class))
+ .thenReturn(enabledFalseResult);
+ when(enabledFalseResult.orElse(true)).thenReturn(false);
+
+ boolean result = resolver.isResolvable(mockContext, location);
+
+ assertFalse(result, "Resolver should reject locations when App Configuration is disabled");
+ }
+
@Test
void testIsResolvableWithValidEndpointsConfiguration() {
ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:");
when(mockContext.getBinder()).thenReturn(mockBinder);
+ when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class))
+ .thenReturn(enabledTrueResult);
+ when(enabledTrueResult.orElse(true)).thenReturn(true);
when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class))
.thenReturn(emptyResult);
when(emptyResult.orElse("")).thenReturn("");
@@ -134,6 +163,9 @@ void testIsResolvableWithValidConnectionStringsConfiguration() {
ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:");
when(mockContext.getBinder()).thenReturn(mockBinder);
+ when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class))
+ .thenReturn(enabledTrueResult);
+ when(enabledTrueResult.orElse(true)).thenReturn(true);
when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class))
.thenReturn(emptyResult);
when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].connection-string", String.class))
@@ -156,6 +188,9 @@ void testIsResolvableWithNoValidConfiguration() {
ConfigDataLocation location = ConfigDataLocation.of("azureAppConfiguration:");
when(mockContext.getBinder()).thenReturn(mockBinder);
+ when(mockBinder.bind("spring.cloud.azure.appconfiguration.enabled", Boolean.class))
+ .thenReturn(enabledTrueResult);
+ when(enabledTrueResult.orElse(true)).thenReturn(true);
when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].endpoint", String.class))
.thenReturn(emptyResult);
when(mockBinder.bind("spring.cloud.azure.appconfiguration.stores[0].connection-string", String.class))
From 80e2d871a8d658bdc455a4c24efcf63b4f4f96b4 Mon Sep 17 00:00:00 2001
From: Matthew Metcalf
Date: Fri, 23 Jan 2026 07:33:46 +0800
Subject: [PATCH 02/22] Collection monitoring (#47648)
* start collection monitoring
* Updating collection monitoring
* Updating State
* Updated docs
* cleaning up refresh
* Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* fixing tests
* New tests
* More new tests
* Update AzureAppConfigDataLoader.java
* Fixing Formatting
* Update AppConfigurationReplicaClient.java
* Code Review comments
* review comments
* code review items
* Format fixing
* fixing tests
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit f6267fcf5ce83b4232f0fb453bce621346f1e106)
---
.../AppConfigurationRefreshUtil.java | 203 ++++++++-----
.../AppConfigurationReplicaClient.java | 40 ++-
...ppConfigurationSnapshotPropertySource.java | 4 +-
.../AzureAppConfigDataLoader.java | 77 ++++-
.../implementation/FeatureFlagClient.java | 19 +-
.../config/implementation/State.java | 127 +++++++--
.../config/implementation/StateHolder.java | 122 ++++++--
.../WatchedConfigurationSettings.java} | 23 +-
.../feature/FeatureFlagState.java | 8 +-
.../AppConfigurationStoreMonitoring.java | 4 +-
.../AppConfigurationRefreshUtilTest.java | 198 +++++++++++--
.../AppConfigurationReplicaClientTest.java | 104 ++++++-
.../AzureAppConfigDataLoaderTest.java | 267 ++++++++++++++++++
.../implementation/FeatureFlagClientTest.java | 145 +++++-----
.../AppConfigurationStoreMonitoringTest.java | 52 +++-
15 files changed, 1120 insertions(+), 273 deletions(-)
rename sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/{feature/FeatureFlags.java => configuration/WatchedConfigurationSettings.java} (54%)
create mode 100644 sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
index d25e616ced98..a7e4d24d4ae3 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
@@ -17,8 +17,8 @@
import com.azure.core.util.Context;
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState;
-import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore;
@@ -30,6 +30,18 @@ public class AppConfigurationRefreshUtil {
private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationRefreshUtil.class);
+ private static final String FEATURE_FLAG_PREFIX = ".appconfig.featureflag/*";
+
+ /**
+ * Functional interface for refresh operations that can throw AppConfigurationStatusException.
+ */
+ @FunctionalInterface
+ private interface RefreshOperation {
+
+ void execute(AppConfigurationReplicaClient client, RefreshEventData eventData, Context context)
+ throws AppConfigurationStatusException;
+ }
+
/**
* Checks all configured stores to determine if any configurations need to be refreshed.
*
@@ -67,66 +79,46 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
if ((notification.getPrimaryToken() != null
&& StringUtils.hasText(notification.getPrimaryToken().getName()))
|| (notification.getSecondaryToken() != null
- && StringUtils.hasText(notification.getPrimaryToken().getName()))) {
+ && StringUtils.hasText(notification.getSecondaryToken().getName()))) {
pushRefresh = true;
}
Context context = new Context("refresh", true).addData(PUSH_REFRESH, pushRefresh);
-
- clientFactory.findActiveClients(originEndpoint);
- AppConfigurationReplicaClient client = clientFactory.getNextActiveClient(originEndpoint, false);
+ clientFactory.findActiveClients(originEndpoint);
if (monitor.isEnabled() && StateHolder.getLoadState(originEndpoint)) {
- while (client != null) {
- try {
- refreshWithTime(client, StateHolder.getState(originEndpoint), monitor.getRefreshInterval(),
- eventData, replicaLookUp, context);
- if (eventData.getDoRefresh()) {
- clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint());
- return eventData;
- }
- // If check didn't throw an error other clients don't need to be checked.
- break;
- } catch (HttpResponseException e) {
- LOGGER.warn(
- "Failed to connect to App Configuration store {} during configuration refresh check. "
- + "Status: {}, Message: {}",
- client.getEndpoint(), e.getResponse().getStatusCode(), e.getMessage());
-
- clientFactory.backoffClient(originEndpoint, client.getEndpoint());
- client = clientFactory.getNextActiveClient(originEndpoint, false);
- }
+ RefreshEventData result = executeRefreshWithRetry(
+ clientFactory,
+ originEndpoint,
+ (client, data, ctx) -> refreshWithTime(client, StateHolder.getState(originEndpoint),
+ monitor.getRefreshInterval(), data, replicaLookUp, ctx),
+ eventData,
+ context,
+ "configuration refresh check");
+ if (result != null) {
+ return result;
}
} else {
- LOGGER.debug("Skipping configuration refresh check for " + originEndpoint);
+ LOGGER.debug("Skipping configuration refresh check for {}", originEndpoint);
}
FeatureFlagStore featureStore = connection.getFeatureFlagStore();
if (featureStore.getEnabled() && StateHolder.getStateFeatureFlag(originEndpoint) != null) {
- client = clientFactory.getNextActiveClient(originEndpoint, false);
- while (client != null) {
- try {
- refreshWithTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(originEndpoint),
- monitor.getFeatureFlagRefreshInterval(), eventData, replicaLookUp, context);
- if (eventData.getDoRefresh()) {
- clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint());
- return eventData;
- }
- // If check didn't throw an error other clients don't need to be checked.
- break;
- } catch (HttpResponseException e) {
- LOGGER.warn(
- "Failed to connect to App Configuration store {} during feature flag refresh check. "
- + "Status: {}, Message: {}",
- client.getEndpoint(), e.getResponse().getStatusCode(), e.getMessage());
-
- clientFactory.backoffClient(originEndpoint, client.getEndpoint());
- client = clientFactory.getNextActiveClient(originEndpoint, false);
- }
+ RefreshEventData result = executeRefreshWithRetry(
+ clientFactory,
+ originEndpoint,
+ (client, data, ctx) -> refreshWithTimeFeatureFlags(client,
+ StateHolder.getStateFeatureFlag(originEndpoint),
+ monitor.getFeatureFlagRefreshInterval(), data, replicaLookUp, ctx),
+ eventData,
+ context,
+ "feature flag refresh check");
+ if (result != null) {
+ return result;
}
} else {
- LOGGER.debug("Skipping feature flag refresh check for " + originEndpoint);
+ LOGGER.debug("Skipping feature flag refresh check for {}", originEndpoint);
}
}
@@ -138,6 +130,47 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
return eventData;
}
+ /**
+ * Executes a refresh operation with automatic retry logic across replica clients.
+ *
+ * @param clientFactory factory for accessing App Configuration clients
+ * @param originEndpoint the endpoint of the origin configuration store
+ * @param operation the refresh operation to execute
+ * @param eventData the refresh event data to update
+ * @param context the operation context
+ * @param checkType description of the check type for logging (e.g., "configuration refresh check")
+ * @return the eventData if refresh is needed, null otherwise
+ */
+ private RefreshEventData executeRefreshWithRetry(
+ AppConfigurationReplicaClientFactory clientFactory,
+ String originEndpoint,
+ RefreshOperation operation,
+ RefreshEventData eventData,
+ Context context,
+ String checkType) {
+ AppConfigurationReplicaClient client = clientFactory.getNextActiveClient(originEndpoint, false);
+
+ while (client != null) {
+ try {
+ operation.execute(client, eventData, context);
+ if (eventData.getDoRefresh()) {
+ clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint());
+ return eventData;
+ }
+ // If check didn't throw an error, other clients don't need to be checked.
+ break;
+ } catch (HttpResponseException e) {
+ LOGGER.warn(
+ "Failed to connect to App Configuration store {} during {}. Status: {}, Message: {}",
+ client.getEndpoint(), checkType, e.getResponse().getStatusCode(), e.getMessage());
+
+ clientFactory.backoffClient(originEndpoint, client.getEndpoint());
+ client = clientFactory.getNextActiveClient(originEndpoint, false);
+ }
+ }
+ return null;
+ }
+
/**
* Performs a refresh check for a specific store client without time constraints. This method is used for refresh
* failure scenarios only.
@@ -158,7 +191,7 @@ static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String or
/**
* Performs a feature flag refresh check for a specific store client. This method is used for refresh failure
* scenarios only.
- *
+ *
* @param featureStoreEnabled whether feature store is enabled
* @param client the client for checking refresh status
* @param context the operation context
@@ -172,7 +205,7 @@ static boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled,
if (featureStoreEnabled && StateHolder.getStateFeatureFlag(endpoint) != null) {
refreshWithoutTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(endpoint), eventData, context);
} else {
- LOGGER.debug("Skipping feature flag refresh check for " + endpoint);
+ LOGGER.debug("Skipping feature flag refresh check for {}", endpoint);
}
return eventData.getDoRefresh();
}
@@ -194,7 +227,15 @@ private static void refreshWithTime(AppConfigurationReplicaClient client, State
throws AppConfigurationStatusException {
if (Instant.now().isAfter(state.getNextRefreshCheck())) {
replicaLookUp.updateAutoFailoverEndpoints();
- refreshWithoutTime(client, state.getWatchKeys(), eventData, context);
+
+ // Check watched configuration settings first if configured
+ List watchedSettings = state.getCollectionWatchKeys();
+ if (watchedSettings != null && !watchedSettings.isEmpty()) {
+ refreshWithoutTimeWatchedConfigurationSettings(client, watchedSettings, eventData, context);
+ } else {
+ // Fall back to traditional watch key monitoring
+ refreshWithoutTime(client, state.getWatchKeys(), eventData, context);
+ }
StateHolder.getCurrentState().updateStateRefresh(state, refreshInterval);
}
@@ -226,6 +267,34 @@ private static void refreshWithoutTime(AppConfigurationReplicaClient client, Lis
}
}
+ /**
+ * Checks configuration watched configuration settings for etag changes without time validation. This method
+ * immediately checks all watched configuration settings selectors for changes regardless of refresh intervals.
+ *
+ * @param client the App Configuration client to use for checking
+ * @param watchedSettings the list of watched configuration settings to watch for changes
+ * @param eventData the refresh event data to update if changes are detected
+ * @param context the operation context
+ * @throws AppConfigurationStatusException if there's an error during the refresh check
+ */
+ private static void refreshWithoutTimeWatchedConfigurationSettings(AppConfigurationReplicaClient client,
+ List watchedSettings, RefreshEventData eventData, Context context)
+ throws AppConfigurationStatusException {
+ for (WatchedConfigurationSettings watchedSetting : watchedSettings) {
+ if (client.checkWatchKeys(watchedSetting.getSettingSelector(), context)) {
+ String eventDataInfo = watchedSetting.getSettingSelector().getKeyFilter();
+
+ // Only one refresh event needs to be called to update all of the
+ // stores, not one for each.
+ LOGGER.info("Configuration Refresh Event triggered by watched configuration settings: {}",
+ eventDataInfo);
+
+ eventData.setMessage(eventDataInfo);
+ return;
+ }
+ }
+ }
+
/**
* Checks feature flag refresh triggers with time-based validation. Only performs the refresh check if the refresh
* interval has elapsed.
@@ -245,15 +314,13 @@ private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient cl
if (date.isAfter(state.getNextRefreshCheck())) {
replicaLookUp.updateAutoFailoverEndpoints();
- for (FeatureFlags featureFlags : state.getWatchKeys()) {
+ for (WatchedConfigurationSettings featureFlags : state.getWatchKeys()) {
if (client.checkWatchKeys(featureFlags.getSettingSelector(), context)) {
- String eventDataInfo = ".appconfig.featureflag/*";
-
- // Only one refresh Event needs to be call to update all of the
+ // Only one refresh event needs to be called to update all of the
// stores, not one for each.
- LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo);
+ LOGGER.info("Configuration Refresh Event triggered by {}", FEATURE_FLAG_PREFIX);
- eventData.setMessage(eventDataInfo);
+ eventData.setMessage(FEATURE_FLAG_PREFIX);
return;
}
@@ -276,15 +343,13 @@ private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient cl
private static void refreshWithoutTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState watchKeys,
RefreshEventData eventData, Context context) throws AppConfigurationStatusException {
- for (FeatureFlags featureFlags : watchKeys.getWatchKeys()) {
+ for (WatchedConfigurationSettings featureFlags : watchKeys.getWatchKeys()) {
if (client.checkWatchKeys(featureFlags.getSettingSelector(), context)) {
- String eventDataInfo = ".appconfig.featureflag/*";
-
- // Only one refresh Event needs to be call to update all of the
+ // Only one refresh event needs to be called to update all of the
// stores, not one for each.
- LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo);
+ LOGGER.info("Configuration Refresh Event triggered by {}", FEATURE_FLAG_PREFIX);
- eventData.setMessage(eventDataInfo);
+ eventData.setMessage(FEATURE_FLAG_PREFIX);
}
}
@@ -292,7 +357,7 @@ private static void refreshWithoutTimeFeatureFlags(AppConfigurationReplicaClient
/**
* Checks the etag values between watched and current configuration settings to determine if a refresh is needed.
- *
+ *
* @param watchSetting the configuration setting being watched for changes
* @param currentTriggerConfiguration the current configuration setting from the store
* @param endpoint the endpoint of the configuration store
@@ -313,9 +378,9 @@ private static void checkETag(ConfigurationSetting watchSetting, ConfigurationSe
String eventDataInfo = watchSetting.getKey();
- // Only one refresh Event needs to be call to update all of the
+ // Only one refresh event needs to be called to update all of the
// stores, not one for each.
- LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo);
+ LOGGER.info("Configuration Refresh Event triggered by {}", eventDataInfo);
eventData.setMessage(eventDataInfo);
}
}
@@ -325,7 +390,7 @@ private static void checkETag(ConfigurationSetting watchSetting, ConfigurationSe
*/
static class RefreshEventData {
- private static final String MSG_TEMPLATE = "Some keys matching %s has been updated since last check.";
+ private static final String MSG_TEMPLATE = "Some keys matching %s have been updated since last check.";
private String message;
@@ -340,7 +405,7 @@ static class RefreshEventData {
/**
* Sets the refresh message using the standard message template.
- *
+ *
* @param prefix the prefix to include in the message (typically a key name)
* @return this RefreshEventData instance for method chaining
*/
@@ -351,7 +416,7 @@ RefreshEventData setMessage(String prefix) {
/**
* Sets the full refresh message and marks that a refresh should occur.
- *
+ *
* @param message the complete message describing the refresh event
* @return this RefreshEventData instance for method chaining
*/
@@ -363,7 +428,7 @@ private RefreshEventData setFullMessage(String message) {
/**
* Gets the refresh event message.
- *
+ *
* @return the message describing what triggered the refresh
*/
public String getMessage() {
@@ -372,7 +437,7 @@ public String getMessage() {
/**
* Indicates whether a refresh should be performed.
- *
+ *
* @return true if a refresh is needed, false otherwise
*/
public boolean getDoRefresh() {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java
index 0cde712d5867..049e0ed65546 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java
@@ -21,7 +21,7 @@
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
import com.azure.data.appconfiguration.models.SettingSelector;
import com.azure.data.appconfiguration.models.SnapshotComposition;
-import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import io.netty.handler.codec.http.HttpResponseStatus;
@@ -150,15 +150,47 @@ List listSettings(SettingSelector settingSelector, Context
}
}
+ /**
+ * Gets configuration settings using watched configuration settings. This method retrieves all settings matching the
+ * selector and captures ETags for collection-based refresh monitoring.
+ *
+ * @param settingSelector selector criteria for configuration settings
+ * @param context Azure SDK context for request correlation
+ * @return WatchedConfigurationSettings containing the retrieved configuration settings and match conditions
+ * @throws HttpResponseException if the request fails
+ */
+ WatchedConfigurationSettings loadWatchedSettings(SettingSelector settingSelector, Context context) {
+ List configurationSettings = new ArrayList<>();
+ List checks = new ArrayList<>();
+ try {
+ client.listConfigurationSettings(settingSelector, context).streamByPage().forEach(pagedResponse -> {
+ checks.add(
+ new MatchConditions().setIfNoneMatch(pagedResponse.getHeaders().getValue(HttpHeaderName.ETAG)));
+ for (ConfigurationSetting setting : pagedResponse.getValue()) {
+ configurationSettings.add(NormalizeNull.normalizeNullLabel(setting));
+ }
+ });
+
+ // Needs to happen after or we don't know if the request succeeded or failed.
+ this.failedAttempts = 0;
+ settingSelector.setMatchConditions(checks);
+ return new WatchedConfigurationSettings(settingSelector, configurationSettings);
+ } catch (HttpResponseException e) {
+ throw handleHttpResponseException(e);
+ } catch (UncheckedIOException e) {
+ throw new AppConfigurationStatusException(e.getMessage(), null, null);
+ }
+ }
+
/**
* Lists feature flags from the Azure App Configuration store.
*
* @param settingSelector selector criteria for feature flags
* @param context Azure SDK context for request correlation
- * @return FeatureFlags containing the retrieved feature flags and match conditions
+ * @return WatchedConfigurationSettings containing the retrieved feature flags and match conditions
* @throws HttpResponseException if the request fails
*/
- FeatureFlags listFeatureFlags(SettingSelector settingSelector, Context context)
+ WatchedConfigurationSettings listFeatureFlags(SettingSelector settingSelector, Context context)
throws HttpResponseException {
List configurationSettings = new ArrayList<>();
List checks = new ArrayList<>();
@@ -175,7 +207,7 @@ FeatureFlags listFeatureFlags(SettingSelector settingSelector, Context context)
// Needs to happen after or we don't know if the request succeeded or failed.
this.failedAttempts = 0;
settingSelector.setMatchConditions(checks);
- return new FeatureFlags(settingSelector, configurationSettings);
+ return new WatchedConfigurationSettings(settingSelector, configurationSettings);
} catch (HttpResponseException e) {
throw handleHttpResponseException(e);
} catch (UncheckedIOException e) {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java
index e585997e05a5..8423ec60e63e 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java
@@ -10,7 +10,7 @@
import com.azure.core.util.Context;
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
-import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
/**
* Azure App Configuration PropertySource unique per Store Label(Profile) combo.
@@ -49,7 +49,7 @@ final class AppConfigurationSnapshotPropertySource extends AppConfigurationAppli
public void initProperties(List trim, Context context) throws InvalidConfigurationPropertyValueException {
processConfigurationSettings(replicaClient.listSettingSnapshot(snapshotName, context), null, trim);
- FeatureFlags featureFlags = new FeatureFlags(null, featureFlagsList);
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, featureFlagsList);
featureFlagClient.proccessFeatureFlags(featureFlags, replicaClient.getEndpoint());
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
index 8daa346b2d34..39ade124ac93 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
@@ -7,6 +7,7 @@
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import org.apache.commons.logging.Log;
@@ -22,7 +23,8 @@
import com.azure.core.util.Context;
import com.azure.data.appconfiguration.models.ConfigurationSetting;
-import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags;
+import com.azure.data.appconfiguration.models.SettingSelector;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification;
@@ -30,7 +32,7 @@
/**
* Azure App Configuration data loader implementation for Spring Boot's ConfigDataLoader.
- *
+ *
* @since 6.0.0
*/
@@ -127,7 +129,7 @@ public ConfigData load(ConfigDataLoaderContext context, AzureAppConfigDataResour
if ((notification.getPrimaryToken() != null
&& StringUtils.hasText(notification.getPrimaryToken().getName()))
|| (notification.getSecondaryToken() != null
- && StringUtils.hasText(notification.getPrimaryToken().getName()))) {
+ && StringUtils.hasText(notification.getSecondaryToken().getName()))) {
pushRefresh = true;
}
// Feature Management needs to be set in the last config store.
@@ -152,7 +154,7 @@ public ConfigData load(ConfigDataLoaderContext context, AzureAppConfigDataResour
// Reverse in order to add Profile specific properties earlier, and last profile comes first
try {
sourceList.addAll(createSettings(currentClient));
- List featureFlags = createFeatureFlags(currentClient);
+ List featureFlags = createFeatureFlags(currentClient);
logger.debug("PropertySource context.");
AppConfigurationStoreMonitoring monitoring = resource.getMonitoring();
@@ -161,13 +163,23 @@ public ConfigData load(ConfigDataLoaderContext context, AzureAppConfigDataResour
monitoring.getFeatureFlagRefreshInterval());
if (monitoring.isEnabled()) {
- // Setting new ETag values for Watch
- List watchKeysSettings = monitoring.getTriggers().stream()
- .map(trigger -> currentClient.getWatchKey(trigger.getKey(), trigger.getLabel(),
- requestContext))
- .toList();
-
- storeState.setState(resource.getEndpoint(), watchKeysSettings, monitoring.getRefreshInterval());
+ // Check if refreshAll is enabled - if so, use watched configuration settings
+ if (monitoring.getTriggers().size() == 0) {
+ // Use watched configuration settings for refresh
+ List watchedConfigurationSettingsList = getWatchedConfigurationSettings(
+ currentClient);
+ storeState.setState(resource.getEndpoint(), Collections.emptyList(),
+ watchedConfigurationSettingsList, monitoring.getRefreshInterval());
+ } else {
+ // Use traditional watch key monitoring
+ List watchKeysSettings = monitoring.getTriggers().stream()
+ .map(trigger -> currentClient.getWatchKey(trigger.getKey(), trigger.getLabel(),
+ requestContext))
+ .toList();
+
+ storeState.setState(resource.getEndpoint(), watchKeysSettings,
+ monitoring.getRefreshInterval());
+ }
}
storeState.setLoadState(resource.getEndpoint(), true); // Success - configuration loaded, exit loop
lastException = null;
@@ -260,16 +272,16 @@ private List createSettings(AppConfigurationRepl
* Creates a list of feature flags from Azure App Configuration.
*
* @param client client for connecting to App Configuration
- * @return a list of FeatureFlags
+ * @return a list of WatchedConfigurationSettings
* @throws Exception creating feature flags failed
*/
- private List createFeatureFlags(AppConfigurationReplicaClient client)
+ private List createFeatureFlags(AppConfigurationReplicaClient client)
throws Exception {
- List featureFlagWatchKeys = new ArrayList<>();
+ List featureFlagWatchKeys = new ArrayList<>();
List profiles = resource.getProfiles().getActive();
for (FeatureFlagKeyValueSelector selectedKeys : resource.getFeatureFlagSelects()) {
- List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client,
+ List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client,
selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), requestContext);
featureFlagWatchKeys.addAll(storesFeatureFlags);
}
@@ -277,6 +289,41 @@ private List createFeatureFlags(AppConfigurationReplicaClient clie
return featureFlagWatchKeys;
}
+ /**
+ * Creates a list of watched configuration settings for configuration settings from Azure App Configuration. This is
+ * used for collection-based refresh monitoring as an alternative to individual watch keys.
+ *
+ * @param client client for connecting to App Configuration
+ * @return a list of WatchedConfigurationSettings for configuration settings
+ * @throws Exception creating watched configuration settings failed
+ */
+ private List getWatchedConfigurationSettings(AppConfigurationReplicaClient client)
+ throws Exception {
+ List watchedConfigurationSettingsList = new ArrayList<>();
+ List selects = resource.getSelects();
+ List profiles = resource.getProfiles().getActive();
+
+ for (AppConfigurationKeyValueSelector selectedKeys : selects) {
+ // Skip snapshots - they don't support watched configuration settings
+ if (StringUtils.hasText(selectedKeys.getSnapshotName())) {
+ continue;
+ }
+
+ // Create watched configuration settings for each label
+ for (String label : selectedKeys.getLabelFilter(profiles)) {
+ SettingSelector settingSelector = new SettingSelector()
+ .setKeyFilter(selectedKeys.getKeyFilter() + "*")
+ .setLabelFilter(label);
+
+ WatchedConfigurationSettings watchedConfigurationSettings = client.loadWatchedSettings(settingSelector,
+ requestContext);
+ watchedConfigurationSettingsList.add(watchedConfigurationSettings);
+ }
+ }
+
+ return watchedConfigurationSettingsList;
+ }
+
/**
* Logs a replica failure with contextual information about the failure scenario and available replicas.
*
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java
index 44cb297b5e27..85ddcf4b54ef 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java
@@ -37,7 +37,7 @@
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagFilter;
import com.azure.data.appconfiguration.models.SettingSelector;
-import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Allocation;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.FeatureTelemetry;
@@ -78,9 +78,9 @@ class FeatureFlagClient {
*
*
*/
- List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter,
+ List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter,
String[] labelFilter, Context context) {
- List loadedFeatureFlags = new ArrayList<>();
+ List loadedFeatureFlags = new ArrayList<>();
String keyFilter = SELECT_ALL_FEATURE_FLAGS;
@@ -95,18 +95,15 @@ List loadFeatureFlags(AppConfigurationReplicaClient replicaClient,
SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter).setLabelFilter(label);
context.addData("FeatureFlagTracing", tracing);
- FeatureFlags features = replicaClient.listFeatureFlags(settingSelector, context);
- loadedFeatureFlags.addAll(proccessFeatureFlags(features, replicaClient.getOriginClient()));
+ WatchedConfigurationSettings features = replicaClient.listFeatureFlags(settingSelector, context);
+ loadedFeatureFlags.add(proccessFeatureFlags(features, replicaClient.getOriginClient()));
}
return loadedFeatureFlags;
}
- List proccessFeatureFlags(FeatureFlags features, String endpoint) {
- List loadedFeatureFlags = new ArrayList<>();
- loadedFeatureFlags.add(features);
-
+ WatchedConfigurationSettings proccessFeatureFlags(WatchedConfigurationSettings features, String endpoint) {
// Reading In Features
- for (ConfigurationSetting setting : features.getFeatureFlags()) {
+ for (ConfigurationSetting setting : features.getConfigurationSettings()) {
if (setting instanceof FeatureFlagConfigurationSetting
&& FEATURE_FLAG_CONTENT_TYPE.equals(setting.getContentType())) {
FeatureFlagConfigurationSetting featureFlag = (FeatureFlagConfigurationSetting) setting;
@@ -114,7 +111,7 @@ List proccessFeatureFlags(FeatureFlags features, String endpoint)
properties.put(featureFlag.getKey(), createFeature(featureFlag, endpoint));
}
}
- return loadedFeatureFlags;
+ return features;
}
/**
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java
index 1d237001e78e..59a1b95150e9 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java
@@ -6,72 +6,159 @@
import java.util.List;
import com.azure.data.appconfiguration.models.ConfigurationSetting;
-
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
+
+/**
+ * Immutable representation of the refresh state for an Azure App Configuration store.
+ *
+ *
+ * Holds configuration watch keys, collection monitoring settings, refresh timing, and attempt tracking for a single
+ * configuration store endpoint. All fields are final to ensure thread-safety and immutability.
+ *
+ *
+ *
+ * State changes are made by creating new instances rather than mutating existing ones, following an immutable design
+ * pattern.
+ *
+ */
class State {
+ /** Configuration settings used as watch keys to trigger refresh events. */
private final List watchKeys;
+ /** Collection monitoring configurations that can trigger refresh events. */
+ private final List collectionWatchKeys;
+
+ /** The next time this store should be checked for refresh. */
private final Instant nextRefreshCheck;
+ /** The endpoint URL of the configuration store. */
private final String originEndpoint;
- private Integer refreshAttempt;
+ /** Number of refresh attempts for exponential backoff calculation. */
+ private int refreshAttempt;
+ /** The refresh interval in seconds. */
private final int refreshInterval;
+ /**
+ * Creates a new State for configuration watch keys without collection monitoring.
+ * @param watchKeys list of configuration watch keys that can trigger a refresh event
+ * @param refreshInterval refresh interval in seconds
+ * @param originEndpoint the endpoint URL of the configuration store
+ */
State(List watchKeys, int refreshInterval, String originEndpoint) {
- this.watchKeys = watchKeys;
- this.refreshInterval = refreshInterval;
- nextRefreshCheck = Instant.now().plusSeconds(refreshInterval);
- this.originEndpoint = originEndpoint;
- this.refreshAttempt = 1;
+ this(watchKeys, null, refreshInterval, originEndpoint);
}
+ /**
+ * Creates a new State with both configuration watch keys and collection monitoring. Sets the initial refresh
+ * attempt to 1 and calculates next refresh time from now.
+ * @param watchKeys list of configuration watch keys that can trigger a refresh event
+ * @param collectionWatchKeys list of collection monitoring configurations that can trigger a refresh event
+ * @param refreshInterval refresh interval in seconds
+ * @param originEndpoint the endpoint URL of the configuration store
+ */
+ State(List watchKeys, List collectionWatchKeys,
+ int refreshInterval, String originEndpoint) {
+ this(watchKeys, collectionWatchKeys, refreshInterval, originEndpoint,
+ Instant.now().plusSeconds(refreshInterval), 1);
+ }
+
+ /**
+ * Creates a new State from an existing state with an updated refresh time. Preserves the current refresh attempt
+ * count.
+ * @param oldState the existing State to copy from
+ * @param newRefresh the new refresh time
+ */
State(State oldState, Instant newRefresh) {
- this.watchKeys = oldState.getWatchKeys();
- this.refreshInterval = oldState.getRefreshInterval();
- this.nextRefreshCheck = newRefresh;
- this.originEndpoint = oldState.getOriginEndpoint();
- this.refreshAttempt = oldState.getRefreshAttempt();
+ this(oldState, newRefresh, oldState.getRefreshAttempt());
+ }
+
+ /**
+ * Creates a new State from an existing state with updated refresh time and attempt count. Used when creating states
+ * with modified refresh attempts for backoff logic.
+ * @param oldState the existing State to copy from
+ * @param newRefresh the new refresh time
+ * @param refreshAttempt the refresh attempt count
+ */
+ State(State oldState, Instant newRefresh, int refreshAttempt) {
+ this(oldState.getWatchKeys(), oldState.getCollectionWatchKeys(), oldState.getRefreshInterval(),
+ oldState.getOriginEndpoint(), newRefresh, refreshAttempt);
+ }
+
+ /**
+ * Primary constructor that initializes all fields. All other constructors delegate to this one. This constructor is
+ * private to enforce the use of the public factory-style constructors.
+ * @param watchKeys list of configuration watch keys
+ * @param collectionWatchKeys list of collection monitoring configurations (may be null)
+ * @param refreshInterval refresh interval in seconds
+ * @param originEndpoint the endpoint URL of the configuration store
+ * @param nextRefreshCheck the next time to check for refresh
+ * @param refreshAttempt the current refresh attempt count
+ */
+ private State(List watchKeys, List collectionWatchKeys,
+ int refreshInterval, String originEndpoint, Instant nextRefreshCheck, int refreshAttempt) {
+ this.watchKeys = watchKeys;
+ this.collectionWatchKeys = collectionWatchKeys;
+ this.refreshInterval = refreshInterval;
+ this.nextRefreshCheck = nextRefreshCheck;
+ this.originEndpoint = originEndpoint;
+ this.refreshAttempt = refreshAttempt;
}
/**
- * @return the watchKeys
+ * Gets the configuration settings used as watch keys for this store.
+ * @return the list of configuration watch keys
*/
public List getWatchKeys() {
return watchKeys;
}
/**
- * @return the nextRefreshCheck
+ * Gets the collection monitoring configurations for this store.
+ * @return the list of collection monitoring configurations, or null if not configured
+ */
+ public List getCollectionWatchKeys() {
+ return collectionWatchKeys;
+ }
+
+ /**
+ * Gets the next time this store should be checked for refresh.
+ * @return the Instant of the next refresh check
*/
public Instant getNextRefreshCheck() {
return nextRefreshCheck;
}
/**
- * @return the originEndpoint
+ * Gets the endpoint URL of the configuration store.
+ * @return the origin endpoint
*/
public String getOriginEndpoint() {
return originEndpoint;
}
/**
- * @return the refreshAttempt
+ * Gets the number of refresh attempts. Used for exponential backoff calculation.
+ * @return the refresh attempt count
*/
- public Integer getRefreshAttempt() {
+ public int getRefreshAttempt() {
return refreshAttempt;
}
/**
- * Adds 1 to the number of refresh attempts
+ * Increments refresh attempt count. Used when a refresh fails to track attempts for backoff logic.
+ * @return the State instance with refreshAttempt incremented by 1
*/
- public void incrementRefreshAttempt() {
+ public State incrementRefreshAttempt() {
this.refreshAttempt += 1;
+ return this;
}
/**
- * @return the refreshInterval
+ * Gets the refresh interval for this store.
+ * @return the refresh interval in seconds
*/
public int getRefreshInterval() {
return refreshInterval;
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
index 8d63ce85ac92..e9ff389e6570 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
@@ -11,95 +11,163 @@
import java.util.concurrent.ConcurrentHashMap;
import com.azure.data.appconfiguration.models.ConfigurationSetting;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState;
-import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags;
+/**
+ * Thread-safe singleton holder for managing refresh state of Azure App Configuration stores.
+ *
+ *
+ * Maintains state for configuration settings, feature flags, and refresh intervals across multiple configuration
+ * stores. Implements exponential backoff for failed refresh attempts and coordinates the timing of refresh operations.
+ *
+ */
final class StateHolder {
+ /** Maximum jitter in seconds to add when expiring state to prevent thundering herd. */
private static final int MAX_JITTER = 15;
+ /** The current singleton instance of StateHolder. */
private static StateHolder currentState;
+ /** Map of configuration store endpoints to their refresh state. */
private final Map state = new ConcurrentHashMap<>();
+ /** Map of configuration store endpoints to their feature flag refresh state. */
private final Map featureFlagState = new ConcurrentHashMap<>();
+ /** Map tracking whether each configuration store has been successfully loaded. */
private final Map loadState = new ConcurrentHashMap<>();
+ /** Number of client-level refresh attempts for backoff calculation. */
private Integer clientRefreshAttempts = 1;
+ /** The next time a forced refresh should occur across all stores. */
private Instant nextForcedRefresh;
StateHolder() {
}
+ /**
+ * Gets the current singleton instance of StateHolder.
+ * @return the current StateHolder instance, or null if not yet initialized
+ */
static StateHolder getCurrentState() {
return currentState;
}
+ /**
+ * Updates the singleton instance to a new StateHolder.
+ * @param newState the new StateHolder instance to set as current
+ * @return the updated StateHolder instance
+ */
static StateHolder updateState(StateHolder newState) {
currentState = newState;
return currentState;
}
/**
+ * Retrieves the refresh state for a specific configuration store.
* @param originEndpoint the endpoint for the origin config store
- * @return the state
+ * @return the State for the specified store, or null if not found
*/
static State getState(String originEndpoint) {
return currentState.getFullState().get(originEndpoint);
}
+ /**
+ * Gets the full map of configuration store states.
+ * @return map of endpoint to State
+ */
private Map getFullState() {
return state;
}
+ /**
+ * Gets the full map of feature flag states.
+ * @return map of endpoint to FeatureFlagState
+ */
private Map getFullFeatureFlagState() {
return featureFlagState;
}
+ /**
+ * Gets the full map of load states.
+ * @return map of endpoint to load status
+ */
private Map getFullLoadState() {
return loadState;
}
/**
+ * Retrieves the feature flag refresh state for a specific configuration store.
* @param originEndpoint the endpoint for the origin config store
- * @return the state
+ * @return the FeatureFlagState for the specified store, or null if not found
*/
static FeatureFlagState getStateFeatureFlag(String originEndpoint) {
return currentState.getFullFeatureFlagState().get(originEndpoint);
}
/**
- * @param originEndpoint the stores origin endpoint
+ * Sets the refresh state for a configuration store.
+ * @param originEndpoint the store's origin endpoint
* @param watchKeys list of configuration watch keys that can trigger a refresh event
- * @param duration refresh duration.
+ * @param duration refresh duration
*/
void setState(String originEndpoint, List watchKeys, Duration duration) {
state.put(originEndpoint, new State(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint));
}
/**
- * @param originEndpoint the stores origin endpoint
+ * Sets the refresh state for a configuration store with collection monitoring.
+ * @param originEndpoint the store's origin endpoint
* @param watchKeys list of configuration watch keys that can trigger a refresh event
- * @param duration refresh duration.
+ * @param collectionWatchKeys list of collection monitoring configurations that can trigger a refresh event
+ * @param duration refresh duration
+ */
+ void setState(String originEndpoint, List watchKeys,
+ List collectionWatchKeys, Duration duration) {
+ state.put(originEndpoint,
+ new State(watchKeys, collectionWatchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint));
+ }
+
+ /**
+ * Sets the feature flag refresh state for a configuration store.
+ * @param originEndpoint the store's origin endpoint
+ * @param watchKeys list of feature flag watch keys that can trigger a refresh event
+ * @param duration refresh duration
*/
- void setStateFeatureFlag(String originEndpoint, List watchKeys,
+ void setStateFeatureFlag(String originEndpoint, List watchKeys,
Duration duration) {
featureFlagState.put(originEndpoint,
new FeatureFlagState(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint));
}
+ /**
+ * Updates the configuration state with a new refresh time based on the duration.
+ * @param state the current State to update
+ * @param duration the duration to add to the current time for the next refresh
+ */
void updateStateRefresh(State state, Duration duration) {
this.state.put(state.getOriginEndpoint(),
new State(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds()))));
}
+ /**
+ * Updates the feature flag state with a new refresh time based on the duration.
+ * @param state the current FeatureFlagState to update
+ * @param duration the duration to add to the current time for the next refresh
+ */
void updateFeatureFlagStateRefresh(FeatureFlagState state, Duration duration) {
this.featureFlagState.put(state.getOriginEndpoint(),
new FeatureFlagState(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds()))));
}
+ /**
+ * Expires the state for a configuration store by setting a new refresh time with random jitter. The jitter helps
+ * prevent thundering herd when multiple stores refresh simultaneously.
+ * @param originEndpoint the endpoint of the store to expire
+ */
void expireState(String originEndpoint) {
State oldState = state.get(originEndpoint);
long wait = (long) (new SecureRandom().nextDouble() * MAX_JITTER);
@@ -111,7 +179,9 @@ void expireState(String originEndpoint) {
}
/**
- * @return the loadState
+ * Checks if a configuration store has been successfully loaded.
+ * @param originEndpoint the endpoint of the store to check
+ * @return true if the store has been loaded, false otherwise
*/
static boolean getLoadState(String originEndpoint) {
return currentState.getFullLoadState().getOrDefault(originEndpoint, false);
@@ -126,15 +196,16 @@ void setLoadState(String originEndpoint, Boolean loaded) {
}
/**
- * @return the nextForcedRefresh
+ * Gets the next time a forced refresh should occur across all stores.
+ * @return the Instant of the next forced refresh, or null if not set
*/
public static Instant getNextForcedRefresh() {
return currentState.nextForcedRefresh;
}
/**
- * Set after load or refresh is successful.
- * @param refreshPeriod the refreshPeriod to set
+ * Sets the next forced refresh time. Called after a successful load or refresh.
+ * @param refreshPeriod the duration from now until the next forced refresh; if null, no refresh is scheduled
*/
public void setNextForcedRefresh(Duration refreshPeriod) {
if (refreshPeriod != null) {
@@ -143,10 +214,11 @@ public void setNextForcedRefresh(Duration refreshPeriod) {
}
/**
- * Sets a minimum value until the next refresh. If a refresh interval has passed or is smaller than the calculated
- * backoff time, the refresh interval is set to the backoff time.
- * @param refreshInterval period between refresh checks.
- * @param defaultMinBackoff min backoff between checks
+ * Updates the next refresh time for all stores using exponential backoff on failures. Sets a minimum value until
+ * the next refresh. If a refresh interval has passed or is smaller than the calculated backoff time, the refresh
+ * interval is set to the backoff time. This prevents excessive refresh attempts during transient failures.
+ * @param refreshInterval period between refresh checks
+ * @param defaultMinBackoff minimum backoff duration between checks in seconds
*/
void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) {
if (refreshInterval != null) {
@@ -160,14 +232,16 @@ void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) {
}
for (Entry entry : state.entrySet()) {
- State state = entry.getValue();
- Instant newRefresh = getNextRefreshCheck(state.getNextRefreshCheck(),
- state.getRefreshAttempt(), (long) state.getRefreshInterval(), defaultMinBackoff);
-
- if (newRefresh.compareTo(entry.getValue().getNextRefreshCheck()) != 0) {
- state.incrementRefreshAttempt();
+ State storeState = entry.getValue();
+ Instant newRefresh = getNextRefreshCheck(storeState.getNextRefreshCheck(),
+ storeState.getRefreshAttempt(), (long) storeState.getRefreshInterval(), defaultMinBackoff);
+
+ State updatedState;
+ if (newRefresh.compareTo(storeState.getNextRefreshCheck()) != 0) {
+ updatedState = new State(storeState.incrementRefreshAttempt(), newRefresh);
+ } else {
+ updatedState = new State(storeState, newRefresh);
}
- State updatedState = new State(state, newRefresh);
this.state.put(entry.getKey(), updatedState);
}
}
@@ -181,7 +255,7 @@ void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) {
* @param defaultMinBackoff min backoff between checks
* @return new Refresh Date
*/
- private Instant getNextRefreshCheck(Instant nextRefreshCheck, Integer attempt, Long interval,
+ private Instant getNextRefreshCheck(Instant nextRefreshCheck, int attempt, Long interval,
Long defaultMinBackoff) {
// The refresh interval is only updated if it is expired.
if (!Instant.now().isAfter(nextRefreshCheck)) {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java
similarity index 54%
rename from sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java
rename to sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java
index 2a1380a3d907..20b09087087e 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java
@@ -1,21 +1,22 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-package com.azure.spring.cloud.appconfiguration.config.implementation.feature;
+package com.azure.spring.cloud.appconfiguration.config.implementation.configuration;
import java.util.List;
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.data.appconfiguration.models.SettingSelector;
-public class FeatureFlags {
+public class WatchedConfigurationSettings {
private SettingSelector settingSelector;
- private List featureFlags;
+ private List configurationSettings;
- public FeatureFlags(SettingSelector settingSelector, List featureFlags) {
+ public WatchedConfigurationSettings(SettingSelector settingSelector,
+ List configurationSettings) {
this.settingSelector = settingSelector;
- this.featureFlags = featureFlags;
+ this.configurationSettings = configurationSettings;
}
/**
@@ -33,17 +34,17 @@ public void setSettingSelector(SettingSelector settingSelector) {
}
/**
- * @return the featureFlags
+ * @return the configurationSettings
*/
- public List getFeatureFlags() {
- return featureFlags;
+ public List getConfigurationSettings() {
+ return configurationSettings;
}
/**
- * @param featureFlags the featureFlags to set
+ * @param configurations the configurations to set
*/
- public void setFeatureFlags(List featureFlags) {
- this.featureFlags = featureFlags;
+ public void setConfigurationSettings(List configurations) {
+ this.configurationSettings = configurations;
}
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java
index ddca37b32140..4df042e96d24 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java
@@ -5,15 +5,17 @@
import java.time.Instant;
import java.util.List;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
+
public class FeatureFlagState {
- private final List watchKeys;
+ private final List watchKeys;
private final Instant nextRefreshCheck;
private final String originEndpoint;
- public FeatureFlagState(List watchKeys, int refreshInterval, String originEndpoint) {
+ public FeatureFlagState(List watchKeys, int refreshInterval, String originEndpoint) {
this.watchKeys = watchKeys;
nextRefreshCheck = Instant.now().plusSeconds(refreshInterval);
this.originEndpoint = originEndpoint;
@@ -28,7 +30,7 @@ public FeatureFlagState(FeatureFlagState oldState, Instant newRefresh) {
/**
* @return the watchKeys
*/
- public List getWatchKeys() {
+ public List getWatchKeys() {
return watchKeys;
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java
index f90a3a573f12..02ae16106ec6 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java
@@ -37,7 +37,7 @@ public final class AppConfigurationStoreMonitoring {
private List triggers = new ArrayList<>();
/**
- * Validation tokens for push notificaiton requests.
+ * Validation tokens for push notification requests.
*/
private PushNotification pushNotification = new PushNotification();
@@ -120,7 +120,7 @@ public void setPushNotification(PushNotification pushNotification) {
@PostConstruct
void validateAndInit() {
if (enabled) {
- Assert.notEmpty(triggers, "Triggers need to be set if refresh is enabled.");
+ // Triggers are not required defaults to use collection monitoring if not set
for (AppConfigurationStoreTrigger trigger : triggers) {
trigger.validateAndInit();
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
index 36c6e0c2e381..6af627e6afad 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
@@ -2,15 +2,6 @@
// Licensed under the MIT License.
package com.azure.spring.cloud.appconfiguration.config.implementation;
-import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL;
-import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_PREFIX;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
@@ -18,6 +9,9 @@
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
@@ -25,6 +19,9 @@
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
import org.mockito.MockitoAnnotations;
import org.mockito.MockitoSession;
import org.mockito.quality.Strictness;
@@ -33,10 +30,12 @@
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
import com.azure.data.appconfiguration.models.SettingSelector;
+import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL;
+import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_PREFIX;
import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationRefreshUtil.RefreshEventData;
import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState;
-import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.AccessToken;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification;
@@ -143,7 +142,8 @@ public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNoChange(TestInfo testI
when(clientMock.getEndpoint()).thenReturn(endpoint);
FeatureFlagState newState = new FeatureFlagState(
- List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)),
+ List.of(new WatchedConfigurationSettings(
+ new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)),
Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint);
// Config Store does return a watch key change.
@@ -191,7 +191,8 @@ public void refreshWithoutTimeFeatureFlagNoChange(TestInfo testInfo) {
when(clientMock.getEndpoint()).thenReturn(endpoint);
FeatureFlagState newState = new FeatureFlagState(
- List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)),
+ List.of(new WatchedConfigurationSettings(
+ new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)),
Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint);
// Config Store doesn't return a watch key change.
@@ -211,7 +212,8 @@ public void refreshWithoutTimeFeatureFlagEtagChanged(TestInfo testInfo) {
endpoint = testInfo.getDisplayName() + ".azconfig.io";
when(clientMock.getEndpoint()).thenReturn(endpoint);
- FeatureFlags featureFlags = new FeatureFlags(new SettingSelector(), watchKeysFeatureFlags);
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(new SettingSelector(),
+ watchKeysFeatureFlags);
FeatureFlagState newState = new FeatureFlagState(List.of(featureFlags),
Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint);
@@ -229,7 +231,10 @@ public void refreshWithoutTimeFeatureFlagEtagChanged(TestInfo testInfo) {
@Test
public void refreshStoresCheckSettingsTestNotEnabled(TestInfo testInfo) {
endpoint = testInfo.getDisplayName() + ".azconfig.io";
- setupFeatureFlagLoad();
+
+ when(connectionManagerMock.getMonitoring()).thenReturn(monitoring);
+ when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore);
+ when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint);
@@ -252,7 +257,10 @@ public void refreshStoresCheckSettingsTestNotEnabled(TestInfo testInfo) {
@Test
public void refreshStoresCheckSettingsTestNotLoaded(TestInfo testInfo) {
endpoint = testInfo.getDisplayName() + ".azconfig.io";
- setupFeatureFlagLoad();
+
+ when(connectionManagerMock.getMonitoring()).thenReturn(monitoring);
+ when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore);
+ when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint);
@@ -343,7 +351,7 @@ public void refreshStoresCheckSettingsTestRefreshTimeNoChange(TestInfo testInfo)
Mockito.any(Context.class));
}
}
-
+
@Test
public void refreshStoresPushRefreshEnabledPrimary(TestInfo testInfo) {
endpoint = testInfo.getDisplayName() + ".azconfig.io";
@@ -380,7 +388,7 @@ public void refreshStoresPushRefreshEnabledPrimary(TestInfo testInfo) {
assertTrue((Boolean) testContext.getData("PushRefresh").get());
}
}
-
+
@Test
public void refreshStoresPushRefreshEnabledSecondary(TestInfo testInfo) {
endpoint = testInfo.getDisplayName() + ".azconfig.io";
@@ -425,7 +433,8 @@ public void refreshStoresCheckSettingsTestTriggerRefresh(TestInfo testInfo) {
when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
// Refresh Time, trigger refresh
- when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))).thenReturn(clientOriginMock);
+ when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true)))
+ .thenReturn(clientOriginMock);
ConfigurationSetting refreshKey = new ConfigurationSetting().setKey(KEY_FILTER).setLabel(EMPTY_LABEL)
.setETag("new");
@@ -507,7 +516,8 @@ public void refreshStoresCheckFeatureFlagTestNoChange(TestInfo testInfo) {
when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(false);
FeatureFlagState newState = new FeatureFlagState(
- List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)),
+ List.of(new WatchedConfigurationSettings(
+ new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)),
Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint);
// Config Store doesn't return a watch key change.
@@ -533,7 +543,8 @@ public void refreshStoresCheckFeatureFlagTestTriggerRefresh(TestInfo testInfo) {
setupFeatureFlagLoad();
when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(true);
- FeatureFlags featureFlags = new FeatureFlags(new SettingSelector(), watchKeysFeatureFlags);
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(new SettingSelector(),
+ watchKeysFeatureFlags);
FeatureFlagState newState = new FeatureFlagState(List.of(featureFlags),
Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint);
@@ -568,7 +579,8 @@ private void setupFeatureFlagLoad() {
when(connectionManagerMock.getMonitoring()).thenReturn(monitoring);
when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore);
when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
- when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))).thenReturn(clientOriginMock);
+ when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true)))
+ .thenReturn(clientOriginMock);
}
private List generateWatchKeys() {
@@ -589,4 +601,148 @@ private List generateFeatureFlagWatchKeys() {
watchKeys.add(currentWatchKey);
return watchKeys;
}
+
+ @Test
+ public void refreshAllWithWatchedConfigurationSettingsTest(TestInfo testInfo) {
+ // Test that when refreshAll is enabled, watched configuration settings are used instead of watch keys
+ endpoint = testInfo.getDisplayName() + ".azconfig.io";
+
+ when(connectionManagerMock.getMonitoring()).thenReturn(monitoring);
+ when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
+ when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true)))
+ .thenReturn(clientOriginMock);
+
+ // Set up watched configuration settings state
+ WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings(
+ new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null);
+ State state = new State(null, List.of(watchedConfigurationSettings),
+ Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint);
+
+ // Config Store returns a change via watched configuration settings
+ when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class)))
+ .thenReturn(true);
+
+ try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
+ stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true);
+ stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(state);
+ stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock);
+
+ RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(
+ clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
+
+ assertTrue(eventData.getDoRefresh());
+ verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
+ // Verify checkWatchKeys is called (watched configuration settings path)
+ verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class),
+ Mockito.any(Context.class));
+ // Verify getWatchKey is NOT called (traditional watch key path)
+ verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
+ Mockito.any(Context.class));
+ }
+ }
+
+ @Test
+ public void refreshAllWithNullWatchKeysTest(TestInfo testInfo) {
+ // Test that when refreshAll is enabled with null watchKeys, watched configuration settings are still used
+ endpoint = testInfo.getDisplayName() + ".azconfig.io";
+
+ when(connectionManagerMock.getMonitoring()).thenReturn(monitoring);
+ FeatureFlagStore disabledFeatureStore = new FeatureFlagStore();
+ disabledFeatureStore.setEnabled(false);
+ when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore);
+ when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
+ when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true)))
+ .thenReturn(clientOriginMock);
+
+ // Set up state with null watch keys but valid watched configuration settings
+ WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings(
+ new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null);
+ State state = new State(null, List.of(watchedConfigurationSettings),
+ Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint);
+
+ when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class)))
+ .thenReturn(false);
+
+ try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
+ stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true);
+ stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(state);
+ stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock);
+
+ RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(
+ clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
+
+ // No change detected, so should not refresh
+ assertFalse(eventData.getDoRefresh());
+ verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class),
+ Mockito.any(Context.class));
+ }
+ }
+
+ @Test
+ public void watchedConfigurationSettingsNoChangeTest(TestInfo testInfo) {
+ // Test that watched configuration settings correctly detect no change
+ endpoint = testInfo.getDisplayName() + ".azconfig.io";
+
+ when(connectionManagerMock.getMonitoring()).thenReturn(monitoring);
+ FeatureFlagStore disabledFeatureStore = new FeatureFlagStore();
+ disabledFeatureStore.setEnabled(false);
+ when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore);
+ when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
+ when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true)))
+ .thenReturn(clientOriginMock);
+
+ WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings(
+ new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL),
+ generateWatchKeys());
+ State state = new State(null, List.of(watchedConfigurationSettings),
+ Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint);
+
+ // Return false indicating no changes detected
+ when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class)))
+ .thenReturn(false);
+
+ try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
+ stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true);
+ stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(state);
+ stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock);
+
+ RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(
+ clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
+
+ assertFalse(eventData.getDoRefresh());
+ verify(currentStateMock, times(1)).updateStateRefresh(Mockito.any(), Mockito.any());
+ }
+ }
+
+ @Test
+ public void watchedConfigurationSettingsWithChangeDetectedTest(TestInfo testInfo) {
+ // Test that watched configuration settings correctly detect changes
+ endpoint = testInfo.getDisplayName() + ".azconfig.io";
+
+ when(connectionManagerMock.getMonitoring()).thenReturn(monitoring);
+ when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
+ when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true)))
+ .thenReturn(clientOriginMock);
+
+ WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings(
+ new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL),
+ generateWatchKeys());
+ State state = new State(null, List.of(watchedConfigurationSettings),
+ Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint);
+
+ // Return true indicating changes detected
+ when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class)))
+ .thenReturn(true);
+
+ try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
+ stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true);
+ stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(state);
+ stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock);
+
+ RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(
+ clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
+
+ assertTrue(eventData.getDoRefresh());
+ }
+ }
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java
index b6b48e7288e6..3e18eb2263ec 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java
@@ -45,6 +45,7 @@
import com.azure.data.appconfiguration.models.SettingSelector;
import com.azure.data.appconfiguration.models.SnapshotComposition;
import com.azure.identity.CredentialUnavailableException;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import reactor.core.publisher.Mono;
@@ -70,7 +71,7 @@ public class AppConfigurationReplicaClientTest {
@Mock
private Response snapshotResponseMock;
-
+
@Mock
private Context contextMock;
@@ -141,13 +142,16 @@ public void listSettingsTest() {
when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock);
when(exceptionMock.getResponse()).thenReturn(responseMock);
when(responseMock.getStatusCode()).thenReturn(429);
- assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock));
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.listSettings(new SettingSelector(), contextMock));
when(responseMock.getStatusCode()).thenReturn(408);
- assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock));
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.listSettings(new SettingSelector(), contextMock));
when(responseMock.getStatusCode()).thenReturn(500);
- assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock));
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.listSettings(new SettingSelector(), contextMock));
when(responseMock.getStatusCode()).thenReturn(499);
assertThrows(HttpResponseException.class, () -> client.listSettings(new SettingSelector(), contextMock));
@@ -170,7 +174,8 @@ public void listFeatureFlagsTest() {
when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any()))
.thenReturn(new PagedIterable<>(pagedFlux));
- assertEquals(configurations, client.listFeatureFlags(new SettingSelector(), contextMock).getFeatureFlags());
+ assertEquals(configurations,
+ client.listFeatureFlags(new SettingSelector(), contextMock).getConfigurationSettings());
when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock);
when(exceptionMock.getResponse()).thenReturn(responseMock);
@@ -196,7 +201,8 @@ public void listSettingsUnknownHostTest() {
when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any()))
.thenThrow(new UncheckedIOException(new UnknownHostException()));
- assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock));
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.listSettings(new SettingSelector(), contextMock));
}
@Test
@@ -206,15 +212,17 @@ public void listSettingsNoCredentialTest() {
when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any()))
.thenThrow(new CredentialUnavailableException("No Credential"));
- assertThrows(CredentialUnavailableException.class, () -> client.listSettings(new SettingSelector(), contextMock));
+ assertThrows(CredentialUnavailableException.class,
+ () -> client.listSettings(new SettingSelector(), contextMock));
}
@Test
public void getWatchNoCredentialTest() {
AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock);
- when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any()))
- .thenThrow(new CredentialUnavailableException("No Credential"));
+ when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(),
+ Mockito.any()))
+ .thenThrow(new CredentialUnavailableException("No Credential"));
assertThrows(CredentialUnavailableException.class, () -> client.getWatchKey("key", "label", contextMock));
}
@@ -265,20 +273,24 @@ public void listSettingSnapshotTest() {
when(clientMock.listConfigurationSettingsForSnapshot(Mockito.any())).thenThrow(exceptionMock);
when(exceptionMock.getResponse()).thenReturn(responseMock);
when(responseMock.getStatusCode()).thenReturn(429);
- assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock));
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.listSettingSnapshot("SnapshotName", contextMock));
when(responseMock.getStatusCode()).thenReturn(408);
- assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock));
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.listSettingSnapshot("SnapshotName", contextMock));
when(responseMock.getStatusCode()).thenReturn(500);
- assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock));
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.listSettingSnapshot("SnapshotName", contextMock));
when(responseMock.getStatusCode()).thenReturn(499);
assertThrows(HttpResponseException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock));
when(clientMock.getSnapshotWithResponse(Mockito.any(), Mockito.any(), Mockito.any()))
.thenThrow(new UncheckedIOException(new UnknownHostException()));
- assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock));
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.listSettingSnapshot("SnapshotName", contextMock));
}
@Test
@@ -340,7 +352,8 @@ public void checkWatchKeysTest() {
when(supplierMock.get()).thenReturn(Mono.just(pagedResponse));
- when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenReturn(new PagedIterable<>(pagedFlux));
+ when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any()))
+ .thenReturn(new PagedIterable<>(pagedFlux));
assertFalse(client.checkWatchKeys(new SettingSelector(), contextMock));
pagedResponse.close();
@@ -349,4 +362,67 @@ public void checkWatchKeysTest() {
}
}
+ @Test
+ public void watchedConfigurationSettingsTest() {
+ AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock);
+
+ ConfigurationSetting setting1 = new ConfigurationSetting().setKey("key1").setLabel("label1");
+ ConfigurationSetting setting2 = new ConfigurationSetting().setKey("key2").setLabel("label2");
+ List configurations = List.of(setting1, setting2);
+
+ PagedFlux pagedFlux = new PagedFlux<>(supplierMock);
+ HttpHeaders headers = new HttpHeaders().add(HttpHeaderName.ETAG, "test-etag-value");
+ PagedResponse pagedResponse = new PagedResponseBase(
+ null, 200, headers, configurations, null, null);
+
+ when(supplierMock.get()).thenReturn(Mono.just(pagedResponse));
+ when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any()))
+ .thenReturn(new PagedIterable<>(pagedFlux));
+
+ SettingSelector selector = new SettingSelector().setKeyFilter("*");
+ WatchedConfigurationSettings result = client.loadWatchedSettings(selector, contextMock);
+
+ assertEquals(2, result.getConfigurationSettings().size());
+ assertEquals("key1", result.getConfigurationSettings().get(0).getKey());
+ assertEquals("key2", result.getConfigurationSettings().get(1).getKey());
+ assertEquals(1, result.getSettingSelector().getMatchConditions().size());
+ assertEquals("test-etag-value", result.getSettingSelector().getMatchConditions().get(0).getIfNoneMatch());
+ assertEquals(0, client.getFailedAttempts());
+ }
+
+ @Test
+ public void watchedConfigurationSettingsErrorTest() {
+ AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock);
+
+ when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock);
+ when(exceptionMock.getResponse()).thenReturn(responseMock);
+ when(responseMock.getStatusCode()).thenReturn(429);
+
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.loadWatchedSettings(new SettingSelector(), contextMock));
+
+ when(responseMock.getStatusCode()).thenReturn(408);
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.loadWatchedSettings(new SettingSelector(), contextMock));
+
+ when(responseMock.getStatusCode()).thenReturn(500);
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.loadWatchedSettings(new SettingSelector(), contextMock));
+
+ when(responseMock.getStatusCode()).thenReturn(499);
+ assertThrows(HttpResponseException.class,
+ () -> client.loadWatchedSettings(new SettingSelector(), contextMock));
+ }
+
+ @Test
+ public void watchedConfigurationSettingsUncheckedIOExceptionTest() {
+ AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock);
+
+ when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any()))
+ .thenThrow(new UncheckedIOException(new IOException("Network error")));
+
+ assertThrows(AppConfigurationStatusException.class,
+ () -> client.loadWatchedSettings(new SettingSelector(), contextMock));
+ }
+
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java
new file mode 100644
index 000000000000..e12c9b57d7db
--- /dev/null
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java
@@ -0,0 +1,267 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.azure.spring.cloud.appconfiguration.config.implementation;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Duration;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.mockito.MockitoSession;
+import org.mockito.quality.Strictness;
+import org.springframework.boot.context.config.Profiles;
+
+import com.azure.core.util.Context;
+import com.azure.data.appconfiguration.models.SettingSelector;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
+import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector;
+import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring;
+import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreTrigger;
+import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore;
+import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore;
+
+public class AzureAppConfigDataLoaderTest {
+
+ @Mock
+ private AppConfigurationReplicaClient clientMock;
+
+ @Mock
+ private WatchedConfigurationSettings watchedConfigurationSettingsMock;
+
+ private AzureAppConfigDataResource resource;
+
+ private ConfigStore configStore;
+
+ private MockitoSession session;
+
+ private static final String ENDPOINT = "https://test.azconfig.io";
+
+ private static final String KEY_FILTER = "/application/*";
+
+ private static final String LABEL_FILTER = "prod";
+
+ @BeforeEach
+ public void setup() {
+ session = Mockito.mockitoSession().initMocks(this).strictness(Strictness.STRICT_STUBS).startMocking();
+ MockitoAnnotations.openMocks(this);
+
+ configStore = new ConfigStore();
+ configStore.setEndpoint(ENDPOINT);
+ configStore.setEnabled(true);
+
+ // Setup feature flags
+ FeatureFlagStore featureFlagStore = new FeatureFlagStore();
+ featureFlagStore.setEnabled(false);
+ configStore.setFeatureFlags(featureFlagStore);
+
+ // Setup basic resource
+ Profiles profiles = Mockito.mock(Profiles.class);
+ lenient().when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER));
+
+ resource = new AzureAppConfigDataResource(true, configStore, profiles, false, Duration.ofMinutes(1));
+ }
+
+ @AfterEach
+ public void cleanup() throws Exception {
+ MockitoAnnotations.openMocks(this).close();
+ session.finishMocking();
+ }
+
+ @Test
+ public void createWatchedConfigurationSettingsWithSingleSelectorTest() throws Exception {
+ // Setup selector
+ AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
+ selector.setKeyFilter(KEY_FILTER);
+ selector.setLabelFilter(LABEL_FILTER);
+ configStore.getSelects().add(selector);
+
+ // Setup mocks
+ when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
+ .thenReturn(watchedConfigurationSettingsMock);
+ // Use reflection to test the private method
+ AzureAppConfigDataLoader loader = createLoader();
+ List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
+
+ // Verify
+ assertNotNull(result);
+ assertEquals(1, result.size());
+
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ verify(clientMock, times(1)).loadWatchedSettings(selectorCaptor.capture(), any(Context.class));
+
+ SettingSelector capturedSelector = selectorCaptor.getValue();
+ assertEquals(KEY_FILTER + "*", capturedSelector.getKeyFilter());
+ assertEquals(LABEL_FILTER, capturedSelector.getLabelFilter());
+ }
+
+ @Test
+ public void createWatchedConfigurationSettingsWithMultipleSelectorsTest() throws Exception {
+ // Setup multiple selectors
+ AppConfigurationKeyValueSelector selector1 = new AppConfigurationKeyValueSelector();
+ selector1.setKeyFilter("/app1/*");
+ selector1.setLabelFilter("dev");
+ configStore.getSelects().add(selector1);
+
+ AppConfigurationKeyValueSelector selector2 = new AppConfigurationKeyValueSelector();
+ selector2.setKeyFilter("/app2/*");
+ selector2.setLabelFilter("prod");
+ configStore.getSelects().add(selector2);
+
+ // Setup mocks
+ when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
+ .thenReturn(watchedConfigurationSettingsMock);
+
+ // Test
+ AzureAppConfigDataLoader loader = createLoader();
+ List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
+
+ // Verify - should create watched configuration settings for both selectors
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ verify(clientMock, times(2)).loadWatchedSettings(any(SettingSelector.class), any(Context.class));
+ }
+
+ @Test
+ public void createWatchedConfigurationSettingsSkipsSnapshotsTest() throws Exception {
+ // Setup selector with snapshot
+ AppConfigurationKeyValueSelector snapshotSelector = new AppConfigurationKeyValueSelector();
+ snapshotSelector.setSnapshotName("my-snapshot");
+ configStore.getSelects().add(snapshotSelector);
+
+ // Setup regular selector
+ AppConfigurationKeyValueSelector regularSelector = new AppConfigurationKeyValueSelector();
+ regularSelector.setKeyFilter(KEY_FILTER);
+ regularSelector.setLabelFilter(LABEL_FILTER);
+ configStore.getSelects().add(regularSelector);
+
+ // Setup mocks
+ when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
+ .thenReturn(watchedConfigurationSettingsMock);
+
+ // Test
+ AzureAppConfigDataLoader loader = createLoader();
+ List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
+
+ // Verify - snapshot should be skipped, only regular selector should be processed
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ verify(clientMock, times(1)).loadWatchedSettings(any(SettingSelector.class), any(Context.class));
+ }
+
+ @Test
+ public void createWatchedConfigurationSettingsWithMultipleLabelsTest() throws Exception {
+ // Setup selector with multiple labels
+ AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
+ selector.setKeyFilter(KEY_FILTER);
+ selector.setLabelFilter("dev,prod,test");
+ configStore.getSelects().add(selector);
+
+ // Setup mocks
+ when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
+ .thenReturn(watchedConfigurationSettingsMock);
+ // Test
+ AzureAppConfigDataLoader loader = createLoader();
+ List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
+
+ // Verify - should create watched configuration settings for each label
+ assertNotNull(result);
+ assertEquals(3, result.size());
+ verify(clientMock, times(3)).loadWatchedSettings(any(SettingSelector.class), any(Context.class));
+ }
+
+ @Test
+ public void refreshAllEnabledUsesWatchedConfigurationSettingsTest() throws Exception {
+ // Setup monitoring with refreshAll enabled
+ AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
+ monitoring.setEnabled(true);
+ configStore.setMonitoring(monitoring);
+
+ // Setup selector
+ AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
+ selector.setKeyFilter(KEY_FILTER);
+ selector.setLabelFilter(LABEL_FILTER);
+ configStore.getSelects().add(selector);
+
+ // Setup mocks
+ when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
+ .thenReturn(watchedConfigurationSettingsMock);
+
+ // Test - verify that watched configuration settings are created when refreshAll is enabled
+ AzureAppConfigDataLoader loader = createLoader();
+ List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
+
+ // Verify watched configuration settings were created
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ verify(clientMock, times(1)).loadWatchedSettings(any(SettingSelector.class), any(Context.class));
+ }
+
+ @Test
+ public void refreshAllDisabledUsesWatchKeysTest() throws Exception {
+ // Setup monitoring with refreshAll disabled (traditional watch keys)
+ AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
+ monitoring.setEnabled(true);
+
+ // Add trigger for traditional watch key
+ AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger();
+ trigger.setKey("sentinel");
+ trigger.setLabel("prod");
+ monitoring.setTriggers(List.of(trigger));
+
+ configStore.setMonitoring(monitoring);
+
+ // Setup selector
+ AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
+ selector.setKeyFilter(KEY_FILTER);
+ selector.setLabelFilter(LABEL_FILTER);
+ configStore.getSelects().add(selector);
+
+ // Verify that when refreshAll is false, triggers are configured
+ // The actual validation happens in validateAndInit which is called during load
+ assertEquals(1, monitoring.getTriggers().size());
+ assertEquals("sentinel", monitoring.getTriggers().get(0).getKey());
+ }
+
+ // Helper methods
+
+ private AzureAppConfigDataLoader createLoader() {
+ org.springframework.boot.logging.DeferredLogFactory logFactory = Mockito
+ .mock(org.springframework.boot.logging.DeferredLogFactory.class);
+ when(logFactory.getLog(any(Class.class))).thenReturn(new org.springframework.boot.logging.DeferredLog());
+ return new AzureAppConfigDataLoader(logFactory);
+ }
+
+ private List invokeGetWatchedConfigurationSettings(
+ AzureAppConfigDataLoader loader, AppConfigurationReplicaClient client) throws Exception {
+ // Set resource field in the loader using reflection
+ java.lang.reflect.Field resourceField = AzureAppConfigDataLoader.class.getDeclaredField("resource");
+ resourceField.setAccessible(true);
+ resourceField.set(loader, resource);
+
+ // Set requestContext field (it can be null for this test)
+ java.lang.reflect.Field requestContextField = AzureAppConfigDataLoader.class.getDeclaredField("requestContext");
+ requestContextField.setAccessible(true);
+ requestContextField.set(loader, Context.NONE);
+
+ // Use reflection to invoke private method
+ java.lang.reflect.Method method = AzureAppConfigDataLoader.class
+ .getDeclaredMethod("getWatchedConfigurationSettings", AppConfigurationReplicaClient.class);
+ method.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ List result = (List) method.invoke(loader, client);
+ return result;
+ }
+}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java
index f2ad1eca8024..91c44e0b1e7c 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java
@@ -36,7 +36,7 @@
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagFilter;
-import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags;
+import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Allocation;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Variant;
@@ -51,15 +51,15 @@ public class FeatureFlagClientTest {
private FeatureFlagClient featureFlagClient;
- private String[] emptyLabelList = {"\0"};
+ private String[] emptyLabelList = { "\0" };
private static final FeatureFlagConfigurationSetting TELEMETRY_FEATURE = createItemFeatureFlag(
- ".appconfig.featureflag/", "Delta",
- FEATURE_VALUE_TELEMETRY, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "Delta",
+ FEATURE_VALUE_TELEMETRY, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
private static final FeatureFlagConfigurationSetting ALL_FEATURE = createItemFeatureFlag(
- ".appconfig.featureflag/", "Delta",
- FEATURE_VALUE_ALL, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "Delta",
+ FEATURE_VALUE_ALL, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
private MockitoSession session;
@@ -80,58 +80,65 @@ public void cleanup() throws Exception {
@Test
public void loadFeatureFlagsTestNoFeatureFlags() {
List settings = List.of(new ConfigurationSetting().setKey("FakeKey"));
- FeatureFlags featureFlags = new FeatureFlags(null, settings);
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings);
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
- List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList,
- contextMock);
+ List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null,
+ emptyLabelList,
+ contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
- assertEquals("FakeKey", featureFlagsList.get(0).getFeatureFlags().get(0).getKey());
+ assertEquals("FakeKey", featureFlagsList.get(0).getConfigurationSettings().get(0).getKey());
assertEquals(0, featureFlagClient.getFeatureFlags().size());
}
@Test
public void loadFeatureFlagsTestFeatureFlags() {
List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false),
- new FeatureFlagConfigurationSetting("Beta", true));
- FeatureFlags featureFlags = new FeatureFlags(null, settings);
+ new FeatureFlagConfigurationSetting("Beta", true));
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings);
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
- List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList,
- contextMock);
+ List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null,
+ emptyLabelList,
+ contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
- assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey());
- assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getFeatureFlags().get(1).getKey());
+ assertEquals(".appconfig.featureflag/Alpha",
+ featureFlagsList.get(0).getConfigurationSettings().get(0).getKey());
+ assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getConfigurationSettings().get(1).getKey());
assertEquals(2, featureFlagClient.getFeatureFlags().size());
}
@Test
public void loadFeatureFlagsTestMultipleLoads() {
List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false),
- new FeatureFlagConfigurationSetting("Beta", true));
- FeatureFlags featureFlags = new FeatureFlags(null, settings);
+ new FeatureFlagConfigurationSetting("Beta", true));
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings);
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
- List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList,
- contextMock);
+ List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null,
+ emptyLabelList,
+ contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
- assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey());
- assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getFeatureFlags().get(1).getKey());
+ assertEquals(".appconfig.featureflag/Alpha",
+ featureFlagsList.get(0).getConfigurationSettings().get(0).getKey());
+ assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getConfigurationSettings().get(1).getKey());
assertEquals(2, featureFlagClient.getFeatureFlags().size());
List settings2 = List.of(new FeatureFlagConfigurationSetting("Alpha", true),
- new FeatureFlagConfigurationSetting("Gamma", false));
- featureFlags = new FeatureFlags(null, settings2);
+ new FeatureFlagConfigurationSetting("Gamma", false));
+ featureFlags = new WatchedConfigurationSettings(null, settings2);
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
- assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey());
- assertEquals(".appconfig.featureflag/Gamma", featureFlagsList.get(0).getFeatureFlags().get(1).getKey());
+ assertEquals(".appconfig.featureflag/Alpha",
+ featureFlagsList.get(0).getConfigurationSettings().get(0).getKey());
+ assertEquals(".appconfig.featureflag/Gamma",
+ featureFlagsList.get(0).getConfigurationSettings().get(1).getKey());
assertEquals(3, featureFlagClient.getFeatureFlags().size());
List features = featureFlagClient.getFeatureFlags();
assertTrue(features.get(0).isEnabled());
@@ -170,14 +177,16 @@ public void loadFeatureFlagsTestTargetingFilter() {
targetingFilter.addParameter("Audience", parameters);
targetingFlag.addClientFilter(targetingFilter);
List settings = List.of(targetingFlag);
- FeatureFlags featureFlags = new FeatureFlags(null, settings);
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings);
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
- List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList,
- contextMock);
+ List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null,
+ emptyLabelList,
+ contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
- assertEquals(".appconfig.featureflag/TargetingTest", featureFlagsList.get(0).getFeatureFlags().get(0).getKey());
+ assertEquals(".appconfig.featureflag/TargetingTest",
+ featureFlagsList.get(0).getConfigurationSettings().get(0).getKey());
assertEquals(1, featureFlagClient.getFeatureFlags().size());
}
@@ -209,9 +218,9 @@ public void testAllocationIdInTelemetry() {
@Test
public void testAllocationIdWithDifferentSeed() {
FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag(
- ".appconfig.featureflag/", "TestFeature",
- "{\"allocation\":{\"seed\":\"newSeed\"},\"telemetry\":{\"enabled\":true}}", FEATURE_LABEL,
- FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "TestFeature",
+ "{\"allocation\":{\"seed\":\"newSeed\"},\"telemetry\":{\"enabled\":true}}", FEATURE_LABEL,
+ FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT);
assertEquals("RkxUK5CoaOaNWBjc55Mi", feature.getTelemetry().getMetadata().get("AllocationId"));
@@ -221,7 +230,7 @@ public void testAllocationIdWithDifferentSeed() {
public void testAllocationIdWithVariants() {
String flagValue = "{\"allocation\": { \"percentile\": [{\"variant\": \"Off\", \"from\": 0, \"to\": 50}, {\"variant\": \"On\", \"from\": 50, \"to\": 100}], \"default_when_enabled\": \"Off2\", \"default_when_disabled\": \"Off\" }, \"telemetry\": {\"enabled\": true}}";
FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag(
- ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT);
assertEquals("wGzzPy4qGy92SHnMtSvY", feature.getTelemetry().getMetadata().get("AllocationId"));
@@ -230,9 +239,9 @@ public void testAllocationIdWithVariants() {
@Test
public void testAllocationIdWithEmptyAllocation() {
FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag(
- ".appconfig.featureflag/", "TestFeature",
- "{\"allocation\":{},\"telemetry\":{\"enabled\":true}}}", FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE,
- TEST_E_TAG);
+ ".appconfig.featureflag/", "TestFeature",
+ "{\"allocation\":{},\"telemetry\":{\"enabled\":true}}}", FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE,
+ TEST_E_TAG);
Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT);
assertNull(feature.getTelemetry().getMetadata().get("AllocationId"));
@@ -241,12 +250,12 @@ public void testAllocationIdWithEmptyAllocation() {
@Test
public void testVariantsParsing() {
String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true,"
- + "\"variants\":["
- + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\",\"status_override\":\"Enabled\"},"
- + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\",\"status_override\":\"None\"}"
- + "]}";
+ + "\"variants\":["
+ + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\",\"status_override\":\"Enabled\"},"
+ + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\",\"status_override\":\"None\"}"
+ + "]}";
FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag(
- ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT);
@@ -267,16 +276,16 @@ public void testVariantsParsing() {
@Test
public void testAllocationParsing() {
String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true,"
- + "\"allocation\":{"
- + "\"default_when_enabled\":\"Red\","
- + "\"default_when_disabled\":\"Off\","
- + "\"seed\":\"testSeed\","
- + "\"user\":[{\"variant\":\"Green\",\"users\":[\"user1\",\"user2\"]}],"
- + "\"group\":[{\"variant\":\"Blue\",\"groups\":[\"group1\"]}],"
- + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]"
- + "}}";
+ + "\"allocation\":{"
+ + "\"default_when_enabled\":\"Red\","
+ + "\"default_when_disabled\":\"Off\","
+ + "\"seed\":\"testSeed\","
+ + "\"user\":[{\"variant\":\"Green\",\"users\":[\"user1\",\"user2\"]}],"
+ + "\"group\":[{\"variant\":\"Blue\",\"groups\":[\"group1\"]}],"
+ + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]"
+ + "}}";
FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag(
- ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT);
@@ -309,16 +318,16 @@ public void testAllocationParsing() {
@Test
public void testVariantsAndAllocationTogether() {
String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true,"
- + "\"variants\":["
- + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\"},"
- + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\"}"
- + "],"
- + "\"allocation\":{"
- + "\"default_when_enabled\":\"Red\","
- + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]"
- + "}}";
+ + "\"variants\":["
+ + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\"},"
+ + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\"}"
+ + "],"
+ + "\"allocation\":{"
+ + "\"default_when_enabled\":\"Red\","
+ + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]"
+ + "}}";
FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag(
- ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT);
@@ -336,14 +345,14 @@ public void testVariantsAndAllocationTogether() {
@Test
public void testVariantsWithComplexConfigurationValue() {
String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true,"
- + "\"variants\":["
- + "{\"name\":\"SimpleString\",\"configuration_value\":\"hello\"},"
- + "{\"name\":\"Number\",\"configuration_value\":42},"
- + "{\"name\":\"Boolean\",\"configuration_value\":true},"
- + "{\"name\":\"Object\",\"configuration_value\":{\"key\":\"value\",\"nested\":{\"prop\":123}}}"
- + "]}";
+ + "\"variants\":["
+ + "{\"name\":\"SimpleString\",\"configuration_value\":\"hello\"},"
+ + "{\"name\":\"Number\",\"configuration_value\":42},"
+ + "{\"name\":\"Boolean\",\"configuration_value\":true},"
+ + "{\"name\":\"Object\",\"configuration_value\":{\"key\":\"value\",\"nested\":{\"prop\":123}}}"
+ + "]}";
FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag(
- ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT);
@@ -361,7 +370,7 @@ public void testVariantsWithComplexConfigurationValue() {
public void testFeatureFlagWithoutVariantsOrAllocation() {
String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true}";
FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag(
- ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
+ ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG);
Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT);
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java
index ab4f2e6c0656..27ff6e4cdb88 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java
@@ -2,13 +2,12 @@
// Licensed under the MIT License.
package com.azure.spring.cloud.appconfiguration.config.implementation.properties;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
public class AppConfigurationStoreMonitoringTest {
@@ -19,15 +18,9 @@ public void validateAndInitTest() {
AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
monitoring.validateAndInit();
- // Enabled throw error if no triggers
- monitoring.setEnabled(true);
- assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit());
-
List triggers = new ArrayList<>();
monitoring.setTriggers(triggers);
- assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit());
-
AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger();
trigger.setKey("sentinal");
@@ -51,4 +44,45 @@ public void validateAndInitTest() {
monitoring.validateAndInit();
}
+ @Test
+ public void refreshAllEnabledWithoutTriggersTest() {
+ // When refreshAll is enabled, triggers are not required
+ AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
+ monitoring.setEnabled(true);
+
+ // Should not throw an exception even with no triggers
+ monitoring.validateAndInit();
+
+ // Verify refresh interval validation still applies
+ monitoring.setRefreshInterval(Duration.ofSeconds(0));
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit());
+ assertEquals("Minimum refresh interval time is 1 Second.", e.getMessage());
+ }
+
+ @Test
+ public void refreshAllWithTriggersTest() {
+ // Even when refreshAll is enabled, having triggers should still be valid
+ AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
+ monitoring.setEnabled(true);
+
+ List triggers = new ArrayList<>();
+ AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger();
+ trigger.setKey("sentinel");
+ triggers.add(trigger);
+ monitoring.setTriggers(triggers);
+
+ // Should not throw an exception
+ monitoring.validateAndInit();
+ }
+
+ @Test
+ public void monitoringDisabledWithRefreshAllTest() {
+ // When monitoring is disabled, refreshAll setting should not matter
+ AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
+ monitoring.setEnabled(false);
+
+ // Should not throw an exception even with no triggers
+ monitoring.validateAndInit();
+ }
+
}
From 1dc189bad10fba84cf4e376dfaf31cf82428c4b8 Mon Sep 17 00:00:00 2001
From: Matthew Metcalf
Date: Tue, 24 Feb 2026 13:40:08 -0800
Subject: [PATCH 03/22] App Configuration Provider - Tag filters (#47985)
* Tag Filter + Updated JavaDocs
* Fixing tests
* Update CHANGELOG.md
* Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* review comments
* Tag Filter util file + tests
* Update ValidationUtilTest.java
* assertsame
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit e43ce23d378cbf1f3229cdde0808f5407f002e93)
---
.../CHANGELOG.md | 4 +
...ationApplicationSettingPropertySource.java | 10 +-
...ppConfigurationSnapshotPropertySource.java | 2 +-
.../AzureAppConfigDataLoader.java | 10 +-
.../implementation/FeatureFlagClient.java | 7 +-
.../config/implementation/ValidationUtil.java | 43 ++++++
.../AppConfigurationKeyValueSelector.java | 105 ++++++++++---
.../AppConfigurationProperties.java | 34 +++--
.../AppConfigurationStoreMonitoring.java | 109 ++++++++-----
.../AppConfigurationStoreTrigger.java | 28 ++--
.../properties/ConfigStore.java | 143 +++++++++++-------
.../FeatureFlagKeyValueSelector.java | 93 +++++++++---
.../properties/FeatureFlagStore.java | 25 ++-
...nApplicationSettingPropertySourceTest.java | 96 +++++++++++-
...nfigurationPropertySourceKeyVaultTest.java | 2 +-
.../implementation/FeatureFlagClientTest.java | 89 ++++++++++-
.../implementation/ValidationUtilTest.java | 131 ++++++++++++++++
.../AppConfigurationKeyValueSelectorTest.java | 57 +++++++
.../FeatureFlagKeyValueSelectorTest.java | 47 ++++++
19 files changed, 864 insertions(+), 171 deletions(-)
create mode 100644 sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtil.java
create mode 100644 sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtilTest.java
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
index becc8268e1d3..e0367ee76d8c 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
@@ -4,10 +4,14 @@
### Features Added
+- Added support for filtering configuration settings and feature flags by tags. Tags can be configured via `spring.cloud.azure.appconfiguration.stores[0].selects[0].tags-filter` for key-value settings and `spring.cloud.azure.appconfiguration.stores[0].feature-flags.selects[0].tags-filter` for feature flags. The value is a list of `tag=value` pairs (e.g., `["env=prod", "team=backend"]`) combined with AND logic.
+
### Breaking Changes
### Bugs Fixed
+- Fixed YAML configuration binding for `label-filter` by adding standard no-arg getter methods to `AppConfigurationKeyValueSelector` and `FeatureFlagKeyValueSelector`, enabling proper type resolution by Spring Boot's `@ConfigurationProperties` binder.
+
### Other Changes
## 6.2.0 (2026-03-25)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java
index 4ac64874a871..dd0c70cf3c8e 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java
@@ -43,14 +43,18 @@ class AppConfigurationApplicationSettingPropertySource extends AppConfigurationP
private final String[] labelFilters;
+ private final List tagsFilter;
+
AppConfigurationApplicationSettingPropertySource(String name, AppConfigurationReplicaClient replicaClient,
- AppConfigurationKeyVaultClientFactory keyVaultClientFactory, String keyFilter, String[] labelFilters) {
+ AppConfigurationKeyVaultClientFactory keyVaultClientFactory, String keyFilter, String[] labelFilters,
+ List tagsFilter) {
// The context alone does not uniquely define a PropertySource, append storeName
// and label to uniquely define a PropertySource
super(name + getLabelName(labelFilters), replicaClient);
this.keyVaultClientFactory = keyVaultClientFactory;
this.keyFilter = keyFilter;
this.labelFilters = labelFilters;
+ this.tagsFilter = tagsFilter;
}
/**
@@ -70,6 +74,10 @@ public void initProperties(List keyPrefixTrimValues, Context context) th
for (String label : labels) {
SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter + "*").setLabelFilter(label);
+ if (tagsFilter != null && !tagsFilter.isEmpty()) {
+ settingSelector.setTagsFilter(tagsFilter);
+ }
+
// * for wildcard match
processConfigurationSettings(replicaClient.listSettings(settingSelector, context), settingSelector.getKeyFilter(),
keyPrefixTrimValues);
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java
index 8423ec60e63e..47c39c3fd920 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java
@@ -32,7 +32,7 @@ final class AppConfigurationSnapshotPropertySource extends AppConfigurationAppli
FeatureFlagClient featureFlagClient) {
// The context alone does not uniquely define a PropertySource, append storeName
// and label to uniquely define a PropertySource
- super(name, replicaClient, keyVaultClientFactory, null, null);
+ super(name, replicaClient, keyVaultClientFactory, null, null, null);
this.snapshotName = snapshotName;
this.featureFlagClient = featureFlagClient;
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
index 39ade124ac93..8d27d1a017cf 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
@@ -260,7 +260,8 @@ private List createSettings(AppConfigurationRepl
} else {
propertySource = new AppConfigurationApplicationSettingPropertySource(
selectedKeys.getKeyFilter() + resource.getEndpoint() + "/", client, keyVaultClientFactory,
- selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles));
+ selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles),
+ selectedKeys.getTagsFilter());
}
propertySource.initProperties(resource.getTrimKeyPrefix(), requestContext);
sourceList.add(propertySource);
@@ -282,7 +283,8 @@ private List createFeatureFlags(AppConfigurationRe
for (FeatureFlagKeyValueSelector selectedKeys : resource.getFeatureFlagSelects()) {
List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client,
- selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), requestContext);
+ selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles),
+ selectedKeys.getTagsFilter(), requestContext);
featureFlagWatchKeys.addAll(storesFeatureFlags);
}
@@ -315,6 +317,10 @@ private List getWatchedConfigurationSettings(AppCo
.setKeyFilter(selectedKeys.getKeyFilter() + "*")
.setLabelFilter(label);
+ if (selectedKeys.getTagsFilter() != null && !selectedKeys.getTagsFilter().isEmpty()) {
+ settingSelector.setTagsFilter(selectedKeys.getTagsFilter());
+ }
+
WatchedConfigurationSettings watchedConfigurationSettings = client.loadWatchedSettings(settingSelector,
requestContext);
watchedConfigurationSettingsList.add(watchedConfigurationSettings);
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java
index 85ddcf4b54ef..18049c5200af 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java
@@ -79,7 +79,7 @@ class FeatureFlagClient {
*
*/
List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter,
- String[] labelFilter, Context context) {
+ String[] labelFilter, List tagsFilter, Context context) {
List loadedFeatureFlags = new ArrayList<>();
String keyFilter = SELECT_ALL_FEATURE_FLAGS;
@@ -93,6 +93,11 @@ List loadFeatureFlags(AppConfigurationReplicaClien
for (String label : labels) {
SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter).setLabelFilter(label);
+
+ if (tagsFilter != null && !tagsFilter.isEmpty()) {
+ settingSelector.setTagsFilter(tagsFilter);
+ }
+
context.addData("FeatureFlagTracing", tracing);
WatchedConfigurationSettings features = replicaClient.listFeatureFlags(settingSelector, context);
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtil.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtil.java
new file mode 100644
index 000000000000..770a3b82c9a6
--- /dev/null
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtil.java
@@ -0,0 +1,43 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.azure.spring.cloud.appconfiguration.config.implementation;
+
+import java.util.List;
+
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+
+/**
+ * Utility class for validating configuration properties.
+ */
+public final class ValidationUtil {
+
+ private ValidationUtil() {
+ // Utility class, prevent instantiation
+ }
+
+ /**
+ * Validates that tag filter entries follow the expected {@code tagName=tagValue} format.
+ * Each entry must contain an equals sign, with non-empty tag name and value on either side.
+ *
+ * @param tagsFilter the list of tag filter expressions to validate
+ * @throws IllegalArgumentException if any tag filter entry is invalid
+ */
+ public static void validateTagsFilter(List tagsFilter) {
+ if (tagsFilter == null) {
+ return;
+ }
+
+ for (String tagFilter : tagsFilter) {
+ Assert.isTrue(StringUtils.hasText(tagFilter),
+ "Tag filter entries must not be null or empty");
+ Assert.isTrue(tagFilter.contains("="),
+ "Tag filter entries must be in tagName=tagValue format");
+ String[] parts = tagFilter.split("=", 2);
+ Assert.isTrue(StringUtils.hasText(parts[0]),
+ "Tag name must not be empty in tag filter: " + tagFilter);
+ Assert.isTrue(parts.length == 2 && StringUtils.hasText(parts[1]),
+ "Tag value must not be empty in tag filter: " + tagFilter);
+ }
+ }
+}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java
index 58c4e8b5cb50..a26431966344 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelector.java
@@ -13,6 +13,8 @@
import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL;
+import com.azure.spring.cloud.appconfiguration.config.implementation.ValidationUtil;
+
import jakarta.annotation.PostConstruct;
import jakarta.validation.constraints.NotNull;
@@ -33,41 +35,49 @@ public final class AppConfigurationKeyValueSelector {
*/
private static final String LABEL_SEPARATOR = ",";
- @NotNull
+
/**
- * Key filter to use when loading configurations. The default value is
- * "/application/". The key filter is used to filter configurations by key.
- * The key filter must be a non-null string that does not contain an asterisk.
+ * Filters configurations by key prefix. Defaults to {@code /application/} when
+ * not explicitly set. Must not be {@code null} or contain asterisks ({@code *}).
*/
+ @NotNull
private String keyFilter = "";
/**
- * Label filter to use when loading configurations. The label filter is used to
- * filter configurations by label. If the label filter is not set, the default
- * value is the current active Spring profiles. If no active profiles are set,
- * then all configurations with no label are loaded. The label filter must be a
- * non-null string that does not contain an asterisk.
+ * Filters configurations by label. When unset, defaults to the active Spring
+ * profiles; if no profiles are active, only configurations with no label are
+ * loaded. Multiple labels can be specified as a comma-separated string. Must
+ * not contain asterisks ({@code *}).
*/
private String labelFilter;
/**
- * Snapshot name to use when loading configurations. The snapshot name is used
- * to load configurations from a snapshot. If the snapshot name is set, the key
- * and label filters must not be set. The snapshot name must be a non-null
- * string that does not contain an asterisk.
+ * Filters configurations by tags. Each entry is interpreted as a tag-based filter,
+ * typically in the {@code tagName=tagValue} format. When multiple entries are
+ * provided, they are combined using AND logic.
+ */
+ private List tagsFilter;
+
+ /**
+ * Loads configurations from a named snapshot. Cannot be used together with
+ * key, label, or tag filters.
*/
private String snapshotName = "";
/**
- * @return the keyFilter
+ * Returns the key filter, defaulting to {@code /application/} when not explicitly set.
+ *
+ * @return the key filter string
*/
public String getKeyFilter() {
return StringUtils.hasText(keyFilter) ? keyFilter : APPLICATION_SETTING_DEFAULT_KEY_FILTER;
}
/**
- * @param keyFilter the keyFilter to set
- * @return AppConfigurationStoreSelects
+ * Sets the key filter used to select configurations by key prefix.
+ *
+ * @param keyFilter the key prefix to filter by
+ * @return this {@link AppConfigurationKeyValueSelector} for chaining
*/
public AppConfigurationKeyValueSelector setKeyFilter(String keyFilter) {
this.keyFilter = keyFilter;
@@ -75,10 +85,22 @@ public AppConfigurationKeyValueSelector setKeyFilter(String keyFilter) {
}
/**
- * @param profiles List of current Spring profiles to default to using is null
- * label is set.
- * @return List of reversed label values, which are split by the separator, the
- * latter label has higher priority
+ * Returns the raw label filter string, or {@code null} if not set.
+ *
+ * @return the label filter
+ */
+ public String getLabelFilter() {
+ return labelFilter;
+ }
+
+ /**
+ * Resolves the label filter into an array of labels. When no label filter is set,
+ * falls back to the active Spring profiles (in reverse priority order). If neither
+ * is available, returns the empty-label sentinel. Returns an empty array when a
+ * snapshot is configured.
+ *
+ * @param profiles the active Spring profiles to use as a fallback
+ * @return an array of resolved labels, ordered from lowest to highest priority
*/
public String[] getLabelFilter(List profiles) {
if (StringUtils.hasText(snapshotName)) {
@@ -110,8 +132,10 @@ public String[] getLabelFilter(List profiles) {
}
/**
- * @param labelFilter the labelFilter to set
- * @return AppConfigurationStoreSelects
+ * Sets the label filter used to select configurations by label.
+ *
+ * @param labelFilter a comma-separated string of labels to filter by
+ * @return this {@link AppConfigurationKeyValueSelector} for chaining
*/
public AppConfigurationKeyValueSelector setLabelFilter(String labelFilter) {
this.labelFilter = labelFilter;
@@ -119,21 +143,49 @@ public AppConfigurationKeyValueSelector setLabelFilter(String labelFilter) {
}
/**
- * @return the snapshot
+ * Returns the list of tag filters, or {@code null} if not set.
+ *
+ * @return the tag filter list
+ */
+ public List getTagsFilter() {
+ return tagsFilter;
+ }
+
+ /**
+ * Sets the tag filters used to select configurations by tags. Each entry is
+ * interpreted as a tag-based filter, typically in the {@code tagName=tagValue}
+ * format. When multiple entries are provided, they are combined using AND logic.
+ *
+ * @param tagsFilter list of tag expressions, typically in {@code tagName=tagValue} format
+ * @return this {@link AppConfigurationKeyValueSelector} for chaining
+ */
+ public AppConfigurationKeyValueSelector setTagsFilter(List tagsFilter) {
+ this.tagsFilter = tagsFilter;
+ return this;
+ }
+
+ /**
+ * Returns the snapshot name, or an empty string if not set.
+ *
+ * @return the snapshot name
*/
public String getSnapshotName() {
return snapshotName;
}
/**
- * @param snapshot the snapshot to set
+ * Sets the snapshot name to load configurations from.
+ *
+ * @param snapshotName the snapshot name
*/
public void setSnapshotName(String snapshotName) {
this.snapshotName = snapshotName;
}
/**
- * Validates key-filter and label-filter are valid.
+ * Validates that key, label, tag, and snapshot filters are well-formed and
+ * mutually compatible. Asterisks are not allowed in key or label filters,
+ * and snapshots cannot be combined with any other filter type.
*/
@PostConstruct
void validateAndInit() {
@@ -145,6 +197,9 @@ void validateAndInit() {
"Snapshots can't use key filters");
Assert.isTrue(!(StringUtils.hasText(labelFilter) && StringUtils.hasText(snapshotName)),
"Snapshots can't use label filters");
+ Assert.isTrue(!(tagsFilter != null && !tagsFilter.isEmpty() && StringUtils.hasText(snapshotName)),
+ "Snapshots can't use tag filters");
+ ValidationUtil.validateTagsFilter(tagsFilter);
}
private String mapLabel(String label) {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
index 8518170fc57c..37578b33d7f3 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
@@ -23,64 +23,78 @@
public class AppConfigurationProperties {
/**
- * Prefix for client configurations for connecting to configuration stores.
+ * Configuration property prefix for Azure App Configuration client settings.
*/
public static final String CONFIG_PREFIX = "spring.cloud.azure.appconfiguration";
private boolean enabled = true;
/**
- * List of Azure App Configuration stores to connect to.
+ * Azure App Configuration store connections. At least one store must be configured.
*/
private List stores = new ArrayList<>();
private Duration refreshInterval;
/**
- * @return the enabled
+ * Returns whether Azure App Configuration is enabled.
+ *
+ * @return {@code true} if enabled, {@code false} otherwise
*/
public boolean isEnabled() {
return enabled;
}
/**
- * @param enabled the enabled to set
+ * Sets whether Azure App Configuration is enabled.
+ *
+ * @param enabled {@code true} to enable, {@code false} to disable
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
/**
- * @return the stores
+ * Returns the list of configured App Configuration stores.
+ *
+ * @return the list of {@link ConfigStore} instances
*/
public List getStores() {
return stores;
}
/**
- * @param stores the stores to set
+ * Sets the list of App Configuration stores to connect to.
+ *
+ * @param stores the list of {@link ConfigStore} instances
*/
public void setStores(List stores) {
this.stores = stores;
}
/**
- * @return the refreshInterval
+ * Returns the interval between configuration refreshes.
+ *
+ * @return the refresh interval, or {@code null} if not set
*/
public Duration getRefreshInterval() {
return refreshInterval;
}
/**
- * @param refreshInterval the refreshInterval to set
+ * Sets the interval between configuration refreshes. Must be at least 1 second.
+ *
+ * @param refreshInterval the refresh interval duration
*/
public void setRefreshInterval(Duration refreshInterval) {
this.refreshInterval = refreshInterval;
}
/**
- * Validates at least one store is configured for use, and that they are valid.
- * @throws IllegalArgumentException when duplicate endpoints are configured
+ * Validates that at least one store is configured with a valid endpoint or
+ * connection string, and that no duplicate endpoints exist.
+ *
+ * @throws IllegalArgumentException if validation fails or duplicate endpoints are found
*/
@PostConstruct
public void validateAndInit() {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java
index 02ae16106ec6..ba5d495cf72e 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java
@@ -11,111 +11,131 @@
import jakarta.annotation.PostConstruct;
/**
- * Properties for Monitoring an Azure App Configuration Store.
+ * Configuration properties for monitoring changes in an Azure App Configuration store.
*/
public final class AppConfigurationStoreMonitoring {
/**
- * If true, the application will check for updates to the configuration store.
- * When set to true at least one trigger must be set.
+ * Enables monitoring for configuration changes. When enabled, at least one
+ * trigger must be configured.
*/
private boolean enabled = false;
/**
- * The minimum time between checks. The minimum valid time is 1s. The default refresh interval is 30s.
+ * Interval between configuration refresh checks. Must be at least 1 second.
+ * Defaults to 30 seconds.
*/
private Duration refreshInterval = Duration.ofSeconds(30);
/**
- * The minimum time between checks of feature flags. The minimum valid time is 1s. The default refresh interval is 30s.
+ * Interval between feature flag refresh checks. Must be at least 1 second.
+ * Defaults to 30 seconds.
*/
private Duration featureFlagRefreshInterval = Duration.ofSeconds(30);
/**
- * List of triggers that will cause a refresh of the configuration store.
+ * Sentinel keys that trigger a configuration refresh when their values change.
*/
private List triggers = new ArrayList<>();
/**
- * Validation tokens for push notification requests.
+ * Configuration for validating push notification refresh requests.
*/
private PushNotification pushNotification = new PushNotification();
/**
- * @return the enabled
+ * Returns whether configuration monitoring is enabled.
+ *
+ * @return {@code true} if monitoring is enabled, {@code false} otherwise
*/
public boolean isEnabled() {
return enabled;
}
/**
- * @param enabled the enabled to set
+ * Sets whether configuration monitoring is enabled.
+ *
+ * @param enabled {@code true} to enable monitoring, {@code false} to disable
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
/**
- * @return the refreshInterval
+ * Returns the interval between configuration refresh checks.
+ *
+ * @return the refresh interval
*/
public Duration getRefreshInterval() {
return refreshInterval;
}
/**
- * The minimum time between checks. The minimum valid time is 1s. The default refresh interval is 30s.
+ * Sets the interval between configuration refresh checks. Must be at least 1 second.
*
- * @param refreshInterval minimum time between refresh checks
+ * @param refreshInterval the refresh interval duration
*/
public void setRefreshInterval(Duration refreshInterval) {
this.refreshInterval = refreshInterval;
}
/**
- * @return the featureFlagRefreshInterval
+ * Returns the interval between feature flag refresh checks.
+ *
+ * @return the feature flag refresh interval
*/
public Duration getFeatureFlagRefreshInterval() {
return featureFlagRefreshInterval;
}
/**
- * The minimum time between checks of feature flags. The minimum valid time is 1s. The default refresh interval is 30s.
- * @param featureFlagRefreshInterval minimum time between refresh checks for feature flags
+ * Sets the interval between feature flag refresh checks. Must be at least 1 second.
+ *
+ * @param featureFlagRefreshInterval the feature flag refresh interval duration
*/
public void setFeatureFlagRefreshInterval(Duration featureFlagRefreshInterval) {
this.featureFlagRefreshInterval = featureFlagRefreshInterval;
}
/**
- * @return the triggers
+ * Returns the list of triggers that initiate a configuration refresh.
+ *
+ * @return the list of {@link AppConfigurationStoreTrigger} instances
*/
public List getTriggers() {
return triggers;
}
/**
- * @param triggers the triggers to set
+ * Sets the list of triggers that initiate a configuration refresh.
+ *
+ * @param triggers the list of {@link AppConfigurationStoreTrigger} instances
*/
public void setTriggers(List triggers) {
this.triggers = triggers;
}
/**
- * @return the pushNotification
+ * Returns the push notification configuration.
+ *
+ * @return the {@link PushNotification} settings
*/
public PushNotification getPushNotification() {
return pushNotification;
}
/**
- * @param pushNotification the pushNotification to set
+ * Sets the push notification configuration.
+ *
+ * @param pushNotification the {@link PushNotification} settings
*/
public void setPushNotification(PushNotification pushNotification) {
this.pushNotification = pushNotification;
}
/**
- * Validates refreshIntervals are at least 1 second, and if enabled triggers are valid.
+ * Validates that refresh intervals are at least 1 second and, when monitoring
+ * is enabled, that all configured triggers are valid.
*/
@PostConstruct
void validateAndInit() {
@@ -130,43 +150,51 @@ void validateAndInit() {
}
/**
- * Push Notification tokens for setting watch interval to 0.
+ * Access tokens used to validate push notification refresh requests.
*/
public static class PushNotification {
/**
- * Validation token for push notification requests.
+ * Primary token for validating push notification requests.
*/
private AccessToken primaryToken = new AccessToken();
/**
- * Secondary validation token for push notification requests.
+ * Secondary (fallback) token for validating push notification requests.
*/
private AccessToken secondaryToken = new AccessToken();
/**
- * @return the primaryToken
+ * Returns the primary access token.
+ *
+ * @return the primary {@link AccessToken}
*/
public AccessToken getPrimaryToken() {
return primaryToken;
}
/**
- * @param primaryToken the primaryToken to set
+ * Sets the primary access token.
+ *
+ * @param primaryToken the primary {@link AccessToken}
*/
public void setPrimaryToken(AccessToken primaryToken) {
this.primaryToken = primaryToken;
}
/**
- * @return the secondaryToken
+ * Returns the secondary access token.
+ *
+ * @return the secondary {@link AccessToken}
*/
public AccessToken getSecondaryToken() {
return secondaryToken;
}
/**
- * @param secondaryToken the secondaryToken to set
+ * Sets the secondary access token.
+ *
+ * @param secondaryToken the secondary {@link AccessToken}
*/
public void setSecondaryToken(AccessToken secondaryToken) {
this.secondaryToken = secondaryToken;
@@ -174,51 +202,60 @@ public void setSecondaryToken(AccessToken secondaryToken) {
}
/**
- * Token used to verifying Push Refresh Requests
+ * A name/secret pair used to verify push notification refresh requests.
*/
public static class AccessToken {
/**
- * Name of the token.
+ * Identifier for this access token.
*/
private String name;
/**
- * Secret for the token.
+ * Secret value used for validation.
*/
private String secret;
/**
- * @return the name
+ * Returns the token name.
+ *
+ * @return the token name, or {@code null} if not set
*/
public String getName() {
return name;
}
/**
- * @param name the name to set
+ * Sets the token name.
+ *
+ * @param name the token name
*/
public void setName(String name) {
this.name = name;
}
/**
- * @return the secret
+ * Returns the token secret.
+ *
+ * @return the token secret, or {@code null} if not set
*/
public String getSecret() {
return secret;
}
/**
- * @param secret the secret to set
+ * Sets the token secret.
+ *
+ * @param secret the token secret
*/
public void setSecret(String secret) {
this.secret = secret;
}
/**
- * Checks if name and secret are not null.
- * @return boolean true if name and secret are not null.
+ * Returns whether this token has both a name and secret configured.
+ *
+ * @return {@code true} if both name and secret are non-null
*/
public boolean isValid() {
return this.name != null && this.secret != null;
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java
index 97499d8b3f23..39301961bd16 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreTrigger.java
@@ -10,52 +10,62 @@
import jakarta.validation.constraints.NotNull;
/**
- * Properties on what Triggers are checked before a refresh is triggered.
+ * A sentinel configuration setting that is monitored for changes to trigger a
+ * configuration refresh.
*/
public final class AppConfigurationStoreTrigger {
/**
- * Key value of the configuration setting checked when looking for changes.
+ * Key of the sentinel configuration setting to monitor. Required.
*/
@NotNull
private String key;
/**
- * Label value of the configuration setting checked when looking for changes.
- * If the label is not set, the default value is no label.
+ * Label of the sentinel configuration setting to monitor. Defaults to the
+ * empty (no label) value when not set.
*/
private String label;
/**
- * @return the key
+ * Returns the key of the sentinel setting.
+ *
+ * @return the sentinel key
*/
public String getKey() {
return key;
}
/**
- * @param key the key to set
+ * Sets the key of the sentinel setting to monitor.
+ *
+ * @param key the sentinel key
*/
public void setKey(String key) {
this.key = key;
}
/**
- * @return the label
+ * Returns the label of the sentinel setting, defaulting to the empty-label
+ * value when not explicitly set.
+ *
+ * @return the resolved label
*/
public String getLabel() {
return mapLabel(label);
}
/**
- * @param label the label to set
+ * Sets the label of the sentinel setting to monitor.
+ *
+ * @param label the sentinel label
*/
public void setLabel(String label) {
this.label = label;
}
/**
- * Validates key isn't null
+ * Validates that the sentinel key is not null.
*/
@PostConstruct
void validateAndInit() {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java
index 6f8a3fa0bf7c..83409f1d16f8 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java
@@ -15,186 +15,208 @@
import jakarta.annotation.PostConstruct;
/**
- * Config Store Properties for Requests to an Azure App Configuration Store.
+ * Connection and behavior properties for an Azure App Configuration store.
*/
public final class ConfigStore {
- private static final String DEFAULT_KEYS = "/application/";
-
/**
- * Endpoint for the Azure Config Service.
+ * Primary endpoint URL for the App Configuration store.
*/
private String endpoint = ""; // Config store endpoint
/**
- * List of endpoints for geo-replicated config store instances. When connecting
- * to Azure App Configuration, the endpoints will failover to the next endpoint
- * in the list if the current endpoint is unreachable.
+ * Endpoint URLs for geo-replicated store instances. Requests fail over to the
+ * next endpoint in the list when the current one is unreachable.
*/
private List endpoints = new ArrayList<>();
/**
- * Connection String for the Azure Config Service.
+ * Primary connection string for the App Configuration store.
*/
private String connectionString;
/**
- * List of connection strings for geo-replicated config store instances. When
- * connecting to Azure App Configuration, the connection strings will failover
- * to the next connection string in the list if the current connection string is
- * unreachable.
+ * Connection strings for geo-replicated store instances. Requests fail over to
+ * the next connection string in the list when the current one is unreachable.
*/
private List connectionStrings = new ArrayList<>();
/**
- * List of key selectors to filter the keys to be retrieved from the Azure
- * Config Service. If no selectors are provided, the default selector will
- * retrieve all keys with the prefix "/application/" and no label.
+ * Key/label selectors that determine which configuration settings to load.
+ * Defaults to a single selector matching the {@code /application/} prefix
+ * with no label.
*/
private List selects = new ArrayList<>();
private FeatureFlagStore featureFlags = new FeatureFlagStore();
/**
- * If true, the Config Store will be enabled. If false, the Config Store will be
- * disabled and no keys will be retrieved from the Config Store.
+ * Enables or disables this config store. When disabled, no settings are
+ * loaded from it.
*/
private boolean enabled = true;
/**
- * Options for monitoring the Config Store.
+ * Monitoring configuration for detecting configuration changes.
*/
private AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
/**
- * List of values to be trimmed from key names before being set to
- * `@ConfigurationProperties`. By default the prefix "/application/" is trimmed
- * from key names. If any trimKeyPrefix values are provided, the default prefix
- * will not be trimmed.
+ * Prefixes to strip from key names before binding to
+ * {@code @ConfigurationProperties}. When set, the default {@code /application/}
+ * prefix is no longer trimmed automatically.
*/
private List trimKeyPrefix;
/**
- * If true, the Config Store will attempt to discover the replica endpoints for
- * the Config Store. If false, the Config Store will not attempt to discover the
- * replica endpoints for the Config Store.
+ * Enables automatic discovery of geo-replicated store endpoints.
*/
private boolean replicaDiscoveryEnabled = true;
/**
- * If true, the Config Store will use load balancing to distribute requests
- * across multiple endpoints.
+ * Enables request distribution across multiple endpoints via load balancing.
*/
private boolean loadBalancingEnabled = false;
/**
- * @return the endpoint
+ * Returns the primary endpoint URL.
+ *
+ * @return the endpoint URL
*/
public String getEndpoint() {
return endpoint;
}
/**
- * @param endpoint the endpoint to set
+ * Sets the primary endpoint URL.
+ *
+ * @param endpoint the endpoint URL
*/
public void setEndpoint(String endpoint) {
this.endpoint = endpoint;
}
/**
- * @return list of endpoints
+ * Returns the list of geo-replicated endpoint URLs.
+ *
+ * @return the endpoint URL list
*/
public List getEndpoints() {
return endpoints;
}
/**
- * @param endpoints list of endpoints to connect to geo-replicated config store
- * instances.
+ * Sets the list of geo-replicated endpoint URLs.
+ *
+ * @param endpoints the endpoint URL list
*/
public void setEndpoints(List endpoints) {
this.endpoints = endpoints;
}
/**
- * @return the connectionString
+ * Returns the primary connection string.
+ *
+ * @return the connection string, or {@code null} if not set
*/
public String getConnectionString() {
return connectionString;
}
/**
- * @param connectionString the connectionString to set
+ * Sets the primary connection string.
+ *
+ * @param connectionString the connection string
*/
public void setConnectionString(String connectionString) {
this.connectionString = connectionString;
}
/**
- * @return connectionStrings
+ * Returns the list of geo-replicated connection strings.
+ *
+ * @return the connection string list
*/
public List getConnectionStrings() {
return connectionStrings;
}
/**
- * @param connectionStrings the connectionStrings to set
+ * Sets the list of geo-replicated connection strings.
+ *
+ * @param connectionStrings the connection string list
*/
public void setConnectionStrings(List connectionStrings) {
this.connectionStrings = connectionStrings;
}
/**
- * @return the enabled
+ * Returns whether this config store is enabled.
+ *
+ * @return {@code true} if enabled, {@code false} otherwise
*/
public boolean isEnabled() {
return enabled;
}
/**
- * @param enabled the enabled to set
+ * Sets whether this config store is enabled.
+ *
+ * @param enabled {@code true} to enable, {@code false} to disable
*/
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
/**
- * @return the selects
+ * Returns the key/label selectors for this store.
+ *
+ * @return the list of {@link AppConfigurationKeyValueSelector} instances
*/
public List getSelects() {
return selects;
}
/**
- * @param selects the selects to set
+ * Sets the key/label selectors for this store.
+ *
+ * @param selects the list of {@link AppConfigurationKeyValueSelector} instances
*/
public void setSelects(List selects) {
this.selects = selects;
}
/**
- * @return the monitoring
+ * Returns the monitoring configuration for this store.
+ *
+ * @return the {@link AppConfigurationStoreMonitoring} settings
*/
public AppConfigurationStoreMonitoring getMonitoring() {
return monitoring;
}
/**
- * @param monitoring the monitoring to set
+ * Sets the monitoring configuration for this store.
+ *
+ * @param monitoring the {@link AppConfigurationStoreMonitoring} settings
*/
public void setMonitoring(AppConfigurationStoreMonitoring monitoring) {
this.monitoring = monitoring;
}
/**
- * @return the featureFlags
+ * Returns the feature flag store configuration.
+ *
+ * @return the {@link FeatureFlagStore} settings
*/
public FeatureFlagStore getFeatureFlags() {
return featureFlags;
}
/**
- * @param featureFlags the featureFlags to set
+ * Sets the feature flag store configuration.
+ *
+ * @param featureFlags the {@link FeatureFlagStore} settings
*/
public void setFeatureFlags(FeatureFlagStore featureFlags) {
this.featureFlags = featureFlags;
@@ -208,55 +230,70 @@ public boolean containsEndpoint(String endpoint) {
}
/**
- * @return the trimKeyPrefix
+ * Returns the key-name prefixes to strip before property binding.
+ *
+ * @return the prefix list, or {@code null} if not set
*/
public List getTrimKeyPrefix() {
return trimKeyPrefix;
}
/**
- * @param trimKeyPrefix the values to be trimmed from key names before being set
- * to `@ConfigurationProperties`
+ * Sets the key-name prefixes to strip before property binding.
+ *
+ * @param trimKeyPrefix the prefix list
*/
public void setTrimKeyPrefix(List trimKeyPrefix) {
this.trimKeyPrefix = trimKeyPrefix;
}
/**
- * @return the replicaDiscoveryEnabled
+ * Returns whether automatic replica endpoint discovery is enabled.
+ *
+ * @return {@code true} if replica discovery is enabled
*/
public boolean isReplicaDiscoveryEnabled() {
return replicaDiscoveryEnabled;
}
/**
- * @param replicaDiscoveryEnabled the replicaDiscoveryEnabled to set
+ * Sets whether automatic replica endpoint discovery is enabled.
+ *
+ * @param replicaDiscoveryEnabled {@code true} to enable replica discovery
*/
public void setReplicaDiscoveryEnabled(boolean replicaDiscoveryEnabled) {
this.replicaDiscoveryEnabled = replicaDiscoveryEnabled;
}
/**
- * @return the loadBalancingEnabled
+ * Returns whether load balancing across endpoints is enabled.
+ *
+ * @return {@code true} if load balancing is enabled
*/
public boolean isLoadBalancingEnabled() {
return loadBalancingEnabled;
}
/**
- * @param loadBalancingEnabled the loadBalancingEnabled to set
+ * Sets whether load balancing across endpoints is enabled.
+ *
+ * @param loadBalancingEnabled {@code true} to enable load balancing
*/
public void setLoadBalancingEnabled(boolean loadBalancingEnabled) {
this.loadBalancingEnabled = loadBalancingEnabled;
}
/**
- * @throws IllegalStateException Connection String URL endpoint is invalid
+ * Initializes default selectors, validates connection settings, extracts
+ * the endpoint from connection strings, and delegates to monitoring and
+ * feature-flag validation.
+ *
+ * @throws IllegalStateException if a connection string contains an invalid endpoint URI
*/
@PostConstruct
public void validateAndInit() {
if (selects.isEmpty()) {
- selects.add(new AppConfigurationKeyValueSelector().setKeyFilter(DEFAULT_KEYS));
+ selects.add(new AppConfigurationKeyValueSelector());
}
for (AppConfigurationKeyValueSelector selectedKeys : selects) {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java
index 4d1de6b90b11..356bf7fa3c00 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelector.java
@@ -13,42 +13,56 @@
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
+import com.azure.spring.cloud.appconfiguration.config.implementation.ValidationUtil;
+
import jakarta.annotation.PostConstruct;
/**
- * Properties on what Selects are checked before loading configurations.
+ * Selector properties that control which feature flags are loaded from an
+ * Azure App Configuration store.
*/
public final class FeatureFlagKeyValueSelector {
/**
- * Label for requesting all configurations with (No Label)
+ * Sentinel array representing the empty (no label) value.
*/
private static final String[] EMPTY_LABEL_ARRAY = { EMPTY_LABEL };
/**
- * Key filter to use when loading feature flags. The provided key filter is
- * appended after the feature flag prefix, ".appconfig.featureflag/". By
- * default, all feature flags are loaded.
+ * Filters feature flags by key suffix, appended after the
+ * {@code .appconfig.featureflag/} prefix. When empty, all feature flags
+ * are loaded.
*/
private String keyFilter = "";
/**
- * Label filter to use when loading feature flags. By default, all feature flags
- * with no label are loaded. The label filter must be a non-null string that
- * does not contain an asterisk.
+ * Filters feature flags by label. When unset, only feature flags with no
+ * label are loaded. Multiple labels can be specified as a comma-separated
+ * string. Must not contain asterisks ({@code *}).
*/
private String labelFilter;
/**
- * @return the keyFilter
+ * Filters feature flags by tags. Each entry is interpreted as a tag-based filter,
+ * typically in the {@code tagName=tagValue} format. When multiple entries are
+ * provided, they are combined using AND logic.
+ */
+ private List tagsFilter;
+
+ /**
+ * Returns the key filter for feature flags.
+ *
+ * @return the key filter string
*/
public String getKeyFilter() {
return keyFilter;
}
/**
- * @param keyFilter the keyFilter to set
- * @return AppConfigurationStoreSelects
+ * Sets the key filter for feature flags.
+ *
+ * @param keyFilter the key suffix to filter by
+ * @return this {@link FeatureFlagKeyValueSelector} for chaining
*/
public FeatureFlagKeyValueSelector setKeyFilter(String keyFilter) {
this.keyFilter = keyFilter;
@@ -56,8 +70,21 @@ public FeatureFlagKeyValueSelector setKeyFilter(String keyFilter) {
}
/**
- * @param profiles List of current Spring profiles to default to using is null label is set.
- * @return List of reversed label values, which are split by the separator, the latter label has higher priority
+ * Returns the raw label filter string, or {@code null} if not set.
+ *
+ * @return the label filter
+ */
+ public String getLabelFilter() {
+ return labelFilter;
+ }
+
+ /**
+ * Resolves the label filter into an array of labels. When no label filter is
+ * set, falls back to the active Spring profiles (in reverse priority order).
+ * If neither is available, returns the empty-label sentinel.
+ *
+ * @param profiles the active Spring profiles to use as a fallback
+ * @return an array of resolved labels, ordered from lowest to highest priority
*/
public String[] getLabelFilter(List profiles) {
if (labelFilter == null && profiles.size() > 0) {
@@ -83,18 +110,20 @@ public String[] getLabelFilter(List profiles) {
}
/**
- * Get all labels as a single String
- *
- * @param profiles current user profiles
- * @return comma separated list of labels
+ * Returns resolved labels as a single comma-separated string.
+ *
+ * @param profiles the active Spring profiles to use as a fallback
+ * @return comma-separated label string
*/
public String getLabelFilterText(List profiles) {
return String.join(",", getLabelFilter(profiles));
}
/**
- * @param labelFilter the labelFilter to set
- * @return AppConfigurationStoreSelects
+ * Sets the label filter for feature flags.
+ *
+ * @param labelFilter a comma-separated string of labels to filter by
+ * @return this {@link FeatureFlagKeyValueSelector} for chaining
*/
public FeatureFlagKeyValueSelector setLabelFilter(String labelFilter) {
this.labelFilter = labelFilter;
@@ -102,13 +131,37 @@ public FeatureFlagKeyValueSelector setLabelFilter(String labelFilter) {
}
/**
- * Validates key-filter and label-filter are valid.
+ * Returns the list of tag filters, or {@code null} if not set.
+ *
+ * @return the tag filter list
+ */
+ public List getTagsFilter() {
+ return tagsFilter;
+ }
+
+ /**
+ * Sets the tag filters used to select feature flags by tags. Each entry is
+ * interpreted as a tag-based filter, typically in the {@code tagName=tagValue}
+ * format. When multiple entries are provided, they are combined using AND logic.
+ *
+ * @param tagsFilter list of tag expressions, typically in {@code tagName=tagValue} format
+ * @return this {@link FeatureFlagKeyValueSelector} for chaining
+ */
+ public FeatureFlagKeyValueSelector setTagsFilter(List tagsFilter) {
+ this.tagsFilter = tagsFilter;
+ return this;
+ }
+
+ /**
+ * Validates that the label filter does not contain asterisks and that tag filters
+ * follow the expected {@code tagName=tagValue} format.
*/
@PostConstruct
void validateAndInit() {
if (labelFilter != null) {
Assert.isTrue(!labelFilter.contains("*"), "LabelFilter must not contain asterisk(*)");
}
+ ValidationUtil.validateTagsFilter(tagsFilter);
}
private String mapLabel(String label) {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java
index 18ee7c2f28b3..606fd920fd1c 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagStore.java
@@ -8,45 +8,58 @@
import jakarta.annotation.PostConstruct;
/**
- * Properties for what needs to be requested from Azure App Configuration for Feature Flags.
+ * Configuration properties for loading feature flags from an Azure App
+ * Configuration store.
*/
public final class FeatureFlagStore {
/**
- * Boolean for if feature flag loading is enabled.
+ * Enables or disables feature flag loading from the store.
*/
private Boolean enabled = false;
private List selects = new ArrayList<>();
/**
- * @return the enabled
+ * Returns whether feature flag loading is enabled.
+ *
+ * @return {@code true} if enabled, {@code false} otherwise
*/
public Boolean getEnabled() {
return enabled;
}
/**
- * @param enabled the enabled to set
+ * Sets whether feature flag loading is enabled.
+ *
+ * @param enabled {@code true} to enable, {@code false} to disable
*/
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
/**
- * @return the selects
+ * Returns the feature flag selectors.
+ *
+ * @return the list of {@link FeatureFlagKeyValueSelector} instances
*/
public List getSelects() {
return selects;
}
/**
- * @param selects the selects to set
+ * Sets the feature flag selectors.
+ *
+ * @param selects the list of {@link FeatureFlagKeyValueSelector} instances
*/
public void setSelects(List selects) {
this.selects = selects;
}
+ /**
+ * Adds a default selector when enabled with none configured, then
+ * validates all selectors.
+ */
@PostConstruct
void validateAndInit() {
if (enabled && selects.size() == 0) {
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java
index af99492615e5..be1bd3534bab 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySourceTest.java
@@ -19,10 +19,12 @@
import static com.azure.spring.cloud.appconfiguration.config.implementation.TestUtils.createItemFeatureFlag;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@@ -30,6 +32,7 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
@@ -40,6 +43,7 @@
import com.azure.core.util.Context;
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
+import com.azure.data.appconfiguration.models.SettingSelector;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
@@ -113,7 +117,7 @@ public void init() {
String[] labelFilter = { "\0" };
propertySource = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock,
- keyVaultClientFactoryMock, KEY_FILTER, labelFilter);
+ keyVaultClientFactoryMock, KEY_FILTER, labelFilter, null);
}
@AfterEach
@@ -192,4 +196,94 @@ public void jsonContentTypeWithInvalidJsonValueTest() {
.isInstanceOf(InvalidConfigurationPropertyValueException.class)
.hasMessageNotContaining(ITEM_INVALID_JSON.getValue());
}
+
+ @Test
+ public void initPropertiesWithTagsFilterTest() throws IOException {
+ // Create a property source with tags filter
+ String[] labelFilter = { "\0" };
+ List tagsFilter = Arrays.asList("env=prod", "team=backend");
+ AppConfigurationApplicationSettingPropertySource taggedPropertySource
+ = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock,
+ keyVaultClientFactoryMock, KEY_FILTER, labelFilter, tagsFilter);
+
+ when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(testItems);
+
+ taggedPropertySource.initProperties(null, contextMock);
+
+ // Capture the SettingSelector passed to listSettings
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ verify(clientMock).listSettings(selectorCaptor.capture(), Mockito.any(Context.class));
+
+ SettingSelector capturedSelector = selectorCaptor.getValue();
+ assertThat(capturedSelector.getTagsFilter()).isNotNull();
+ assertThat(capturedSelector.getTagsFilter()).hasSize(2);
+ assertThat(capturedSelector.getTagsFilter()).containsExactly("env=prod", "team=backend");
+ }
+
+ @Test
+ public void initPropertiesWithNullTagsFilterTest() throws IOException {
+ // Create a property source with null tags filter (default behavior)
+ String[] labelFilter = { "\0" };
+ AppConfigurationApplicationSettingPropertySource untaggedPropertySource
+ = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock,
+ keyVaultClientFactoryMock, KEY_FILTER, labelFilter, null);
+
+ when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(testItems);
+
+ untaggedPropertySource.initProperties(null, contextMock);
+
+ // Capture the SettingSelector passed to listSettings
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ verify(clientMock).listSettings(selectorCaptor.capture(), Mockito.any(Context.class));
+
+ SettingSelector capturedSelector = selectorCaptor.getValue();
+ // Tags filter should not be set when null
+ assertThat(capturedSelector.getTagsFilter()).isNull();
+ }
+
+ @Test
+ public void initPropertiesWithEmptyTagsFilterTest() throws IOException {
+ // Create a property source with empty tags filter
+ String[] labelFilter = { "\0" };
+ List tagsFilter = new ArrayList<>();
+ AppConfigurationApplicationSettingPropertySource emptyTagPropertySource
+ = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock,
+ keyVaultClientFactoryMock, KEY_FILTER, labelFilter, tagsFilter);
+
+ when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(testItems);
+
+ emptyTagPropertySource.initProperties(null, contextMock);
+
+ // Capture the SettingSelector passed to listSettings
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ verify(clientMock).listSettings(selectorCaptor.capture(), Mockito.any(Context.class));
+
+ SettingSelector capturedSelector = selectorCaptor.getValue();
+ // Tags filter should not be set when empty
+ assertThat(capturedSelector.getTagsFilter()).isNull();
+ }
+
+ @Test
+ public void initPropertiesWithTagsFilterMultipleLabelsTest() throws IOException {
+ // Create a property source with tags filter and multiple labels
+ String[] labelFilter = { "dev", "prod" };
+ List tagsFilter = Arrays.asList("env=staging");
+ AppConfigurationApplicationSettingPropertySource multiLabelPropertySource
+ = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, clientMock,
+ keyVaultClientFactoryMock, KEY_FILTER, labelFilter, tagsFilter);
+
+ when(clientMock.listSettings(Mockito.any(), Mockito.any(Context.class))).thenReturn(testItems);
+
+ multiLabelPropertySource.initProperties(null, contextMock);
+
+ // Capture all SettingSelector instances passed to listSettings (one per label)
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ verify(clientMock, Mockito.times(2)).listSettings(selectorCaptor.capture(), Mockito.any(Context.class));
+
+ // Both calls should have the tags filter set
+ for (SettingSelector capturedSelector : selectorCaptor.getAllValues()) {
+ assertThat(capturedSelector.getTagsFilter()).isNotNull();
+ assertThat(capturedSelector.getTagsFilter()).containsExactly("env=staging");
+ }
+ }
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java
index 74d97b275702..e3b5ff4f8bd6 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPropertySourceKeyVaultTest.java
@@ -88,7 +88,7 @@ public void init() {
String[] labelFilter = { "\0" };
propertySource = new AppConfigurationApplicationSettingPropertySource(TEST_STORE_NAME, replicaClientMock,
- keyVaultClientFactoryMock, KEY_FILTER, labelFilter);
+ keyVaultClientFactoryMock, KEY_FILTER, labelFilter, null);
}
@AfterEach
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java
index 91c44e0b1e7c..ea4b036fbae3 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java
@@ -20,12 +20,14 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
+import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
@@ -36,6 +38,7 @@
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagFilter;
+import com.azure.data.appconfiguration.models.SettingSelector;
import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Allocation;
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature;
@@ -84,7 +87,7 @@ public void loadFeatureFlagsTestNoFeatureFlags() {
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null,
- emptyLabelList,
+ emptyLabelList, null,
contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
@@ -100,7 +103,7 @@ public void loadFeatureFlagsTestFeatureFlags() {
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null,
- emptyLabelList,
+ emptyLabelList, null,
contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
@@ -118,7 +121,7 @@ public void loadFeatureFlagsTestMultipleLoads() {
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null,
- emptyLabelList,
+ emptyLabelList, null,
contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
@@ -132,7 +135,7 @@ public void loadFeatureFlagsTestMultipleLoads() {
featureFlags = new WatchedConfigurationSettings(null, settings2);
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
- featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock);
+ featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, null, contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
assertEquals(".appconfig.featureflag/Alpha",
@@ -181,7 +184,7 @@ public void loadFeatureFlagsTestTargetingFilter() {
when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null,
- emptyLabelList,
+ emptyLabelList, null,
contextMock);
assertEquals(1, featureFlagsList.size());
assertEquals(featureFlags, featureFlagsList.get(0));
@@ -379,4 +382,80 @@ public void testFeatureFlagWithoutVariantsOrAllocation() {
assertEquals("TestFeature", feature.getId());
assertTrue(feature.isEnabled());
}
+
+ @Test
+ public void loadFeatureFlagsWithTagsFilterTest() {
+ List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false));
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings);
+ when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
+
+ List tagsFilter = Arrays.asList("env=prod", "team=backend");
+ featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, tagsFilter, contextMock);
+
+ // Capture the SettingSelector passed to listFeatureFlags
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ Mockito.verify(clientMock).listFeatureFlags(selectorCaptor.capture(), Mockito.any(Context.class));
+
+ SettingSelector capturedSelector = selectorCaptor.getValue();
+ assertEquals(2, capturedSelector.getTagsFilter().size());
+ assertEquals("env=prod", capturedSelector.getTagsFilter().get(0));
+ assertEquals("team=backend", capturedSelector.getTagsFilter().get(1));
+ }
+
+ @Test
+ public void loadFeatureFlagsWithNullTagsFilterTest() {
+ List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false));
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings);
+ when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
+
+ featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, null, contextMock);
+
+ // Capture the SettingSelector passed to listFeatureFlags
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ Mockito.verify(clientMock).listFeatureFlags(selectorCaptor.capture(), Mockito.any(Context.class));
+
+ SettingSelector capturedSelector = selectorCaptor.getValue();
+ // Tags filter should not be set when null
+ assertNull(capturedSelector.getTagsFilter());
+ }
+
+ @Test
+ public void loadFeatureFlagsWithEmptyTagsFilterTest() {
+ List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false));
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings);
+ when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
+
+ List emptyTags = List.of();
+ featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, emptyTags, contextMock);
+
+ // Capture the SettingSelector passed to listFeatureFlags
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ Mockito.verify(clientMock).listFeatureFlags(selectorCaptor.capture(), Mockito.any(Context.class));
+
+ SettingSelector capturedSelector = selectorCaptor.getValue();
+ // Tags filter should not be set when empty
+ assertNull(capturedSelector.getTagsFilter());
+ }
+
+ @Test
+ public void loadFeatureFlagsWithTagsFilterMultipleLabelsTest() {
+ List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false));
+ WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings);
+ when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags);
+
+ String[] multiLabelList = { "dev", "prod" };
+ List tagsFilter = Arrays.asList("env=staging");
+ featureFlagClient.loadFeatureFlags(clientMock, null, multiLabelList, tagsFilter, contextMock);
+
+ // Capture all SettingSelector instances passed to listFeatureFlags (one per label)
+ ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
+ Mockito.verify(clientMock, Mockito.times(2)).listFeatureFlags(selectorCaptor.capture(),
+ Mockito.any(Context.class));
+
+ // Both calls should have the tags filter set
+ for (SettingSelector capturedSelector : selectorCaptor.getAllValues()) {
+ assertEquals(1, capturedSelector.getTagsFilter().size());
+ assertEquals("env=staging", capturedSelector.getTagsFilter().get(0));
+ }
+ }
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtilTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtilTest.java
new file mode 100644
index 000000000000..7f7109690b04
--- /dev/null
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ValidationUtilTest.java
@@ -0,0 +1,131 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+package com.azure.spring.cloud.appconfiguration.config.implementation;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+public class ValidationUtilTest {
+
+ @Test
+ public void testValidateTagsFilterNullList() {
+ assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(null));
+ }
+
+ @Test
+ public void testValidateTagsFilterEmptyList() {
+ List tagsFilter = Collections.emptyList();
+ assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter));
+ }
+
+ @Test
+ public void testValidateTagsFilterValidSingleTag() {
+ List tagsFilter = Collections.singletonList("env=prod");
+ assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter));
+ }
+
+ @Test
+ public void testValidateTagsFilterValidMultipleTags() {
+ List tagsFilter = Arrays.asList("env=prod", "team=backend", "region=us-east");
+ assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter));
+ }
+
+ @Test
+ public void testValidateTagsFilterValidTagWithSpecialCharactersInValue() {
+ List tagsFilter = Collections.singletonList("version=1.0.0-beta");
+ assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter));
+ }
+
+ @Test
+ public void testValidateTagsFilterValidTagWithEqualsInValue() {
+ List tagsFilter = Collections.singletonList("formula=x=y+z");
+ assertDoesNotThrow(() -> ValidationUtil.validateTagsFilter(tagsFilter));
+ }
+
+ @Test
+ public void testValidateTagsFilterNullEntry() {
+ List tagsFilter = Arrays.asList("env=prod", null);
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag filter entries must not be null or empty", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterEmptyEntry() {
+ List tagsFilter = Arrays.asList("env=prod", "");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag filter entries must not be null or empty", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterWhitespaceOnlyEntry() {
+ List tagsFilter = Arrays.asList("env=prod", " ");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag filter entries must not be null or empty", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterMissingEqualsSeparator() {
+ List tagsFilter = Collections.singletonList("envprod");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag filter entries must be in tagName=tagValue format", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterEmptyTagName() {
+ List tagsFilter = Collections.singletonList("=prod");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag name must not be empty in tag filter: =prod", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterWhitespaceOnlyTagName() {
+ List tagsFilter = Collections.singletonList(" =prod");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag name must not be empty in tag filter: =prod", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterEmptyTagValue() {
+ List tagsFilter = Collections.singletonList("env=");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag value must not be empty in tag filter: env=", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterWhitespaceOnlyTagValue() {
+ List tagsFilter = Collections.singletonList("env= ");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag value must not be empty in tag filter: env= ", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterMultipleInvalidEntriesFailsOnFirst() {
+ List tagsFilter = Arrays.asList("=invalid", "alsoInvalid", "env=valid");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag name must not be empty in tag filter: =invalid", exception.getMessage());
+ }
+
+ @Test
+ public void testValidateTagsFilterMixedValidAndInvalidEntries() {
+ List tagsFilter = Arrays.asList("env=prod", "team=backend", "invalid");
+ IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
+ () -> ValidationUtil.validateTagsFilter(tagsFilter));
+ assertEquals("Tag filter entries must be in tagName=tagValue format", exception.getMessage());
+ }
+}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelectorTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelectorTest.java
index 87f0a24060fa..e02e103546d3 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelectorTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationKeyValueSelectorTest.java
@@ -8,6 +8,8 @@
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
@@ -256,4 +258,59 @@ public void profilesListModificationSafetyTest() {
// Original list should remain unchanged
assertEquals(profilesCopy, originalProfiles);
}
+
+ @Test
+ public void getTagsFilterDefaultTest() {
+ // When no tags filter is set, should return null
+ assertNull(selector.getTagsFilter());
+ }
+
+ @Test
+ public void getTagsFilterCustomTest() {
+ // When custom tags filter is set
+ List tags = Arrays.asList("env=prod", "team=backend");
+ selector.setTagsFilter(tags);
+ List result = selector.getTagsFilter();
+ assertEquals(2, result.size());
+ assertEquals("env=prod", result.get(0));
+ assertEquals("team=backend", result.get(1));
+ }
+
+ @Test
+ public void setTagsFilterReturnsSelectorTest() {
+ // Test fluent API returns the selector
+ AppConfigurationKeyValueSelector returned = selector.setTagsFilter(Arrays.asList("env=dev"));
+ assertSame(selector, returned);
+ }
+
+ @Test
+ public void validateAndInitSnapshotWithTagsFilterTest() {
+ // Test snapshot with tags filter (should fail)
+ selector.setTagsFilter(Arrays.asList("env=prod"));
+ selector.setSnapshotName("test-snapshot");
+
+ assertThrows(IllegalArgumentException.class, () -> {
+ selector.validateAndInit();
+ });
+ }
+
+ @Test
+ public void validateAndInitValidWithTagsFilterTest() {
+ // Test valid configuration with tags filter
+ selector.setKeyFilter("/valid/");
+ selector.setTagsFilter(Arrays.asList("env=prod", "team=backend"));
+
+ // Should not throw any exception
+ selector.validateAndInit();
+ }
+
+ @Test
+ public void validateAndInitEmptyTagsFilterWithSnapshotTest() {
+ // Test snapshot with empty tags filter list (should not fail)
+ selector.setTagsFilter(new ArrayList<>());
+ selector.setSnapshotName("test-snapshot");
+
+ // Should not throw any exception
+ selector.validateAndInit();
+ }
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelectorTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelectorTest.java
index b8259ec5b0cc..5a3cf81281fb 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelectorTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/FeatureFlagKeyValueSelectorTest.java
@@ -4,10 +4,13 @@
import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -72,4 +75,48 @@ public void getLabelFilterTest() {
assertEquals("test1", labels[1]);
}
+ @Test
+ public void getTagsFilterDefaultTest() {
+ FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector();
+ // When no tags filter is set, should return null
+ assertNull(selector.getTagsFilter());
+ }
+
+ @Test
+ public void getTagsFilterCustomTest() {
+ FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector();
+ List tags = Arrays.asList("env=prod", "team=backend");
+ selector.setTagsFilter(tags);
+
+ List result = selector.getTagsFilter();
+ assertEquals(2, result.size());
+ assertEquals("env=prod", result.get(0));
+ assertEquals("team=backend", result.get(1));
+ }
+
+ @Test
+ public void setTagsFilterReturnsSelectorTest() {
+ FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector();
+ FeatureFlagKeyValueSelector returned = selector.setTagsFilter(Arrays.asList("env=dev"));
+ assertSame(selector, returned);
+ }
+
+ @Test
+ public void setTagsFilterEmptyListTest() {
+ FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector();
+ selector.setTagsFilter(new ArrayList<>());
+ List result = selector.getTagsFilter();
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ public void setTagsFilterSingleTagTest() {
+ FeatureFlagKeyValueSelector selector = new FeatureFlagKeyValueSelector();
+ selector.setTagsFilter(Arrays.asList("environment=production"));
+
+ List result = selector.getTagsFilter();
+ assertEquals(1, result.size());
+ assertEquals("environment=production", result.get(0));
+ }
+
}
From 4829bc8a7c7297ae15137bb0fdd39d92965ba6c5 Mon Sep 17 00:00:00 2001
From: Matthew Metcalf
Date: Mon, 2 Mar 2026 11:21:33 -0800
Subject: [PATCH 04/22] Load balance bug (#48121)
* Fixes Load Balancing Bug
* fixes
* new version
* Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit c23770387a07b01f3ee9da44542bb180485377f0)
---
.../CHANGELOG.md | 1 +
.../AppConfigurationRefreshUtil.java | 15 ++++++------
.../AppConfigurationReplicaClientFactory.java | 10 --------
.../implementation/ConnectionManager.java | 23 +++++--------------
.../AppConfigurationRefreshUtilTest.java | 13 -----------
.../implementation/ConnectionManagerTest.java | 2 +-
6 files changed, 16 insertions(+), 48 deletions(-)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
index e0367ee76d8c..adb378c1babe 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
@@ -10,6 +10,7 @@
### Bugs Fixed
+- Fixed an issue where feature flag–based refresh did not work when load balancing was enabled with a single configuration store. Feature flag refresh now uses the same load-balanced client selection as configuration refresh, including the single-store scenario.
- Fixed YAML configuration binding for `label-filter` by adding standard no-arg getter methods to `AppConfigurationKeyValueSelector` and `FeatureFlagKeyValueSelector`, enabling proper type resolution by Spring Boot's `@ConfigurationProperties` binder.
### Other Changes
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
index a7e4d24d4ae3..e17572efa6a9 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
@@ -69,8 +69,6 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
for (Entry entry : clientFactory.getConnections().entrySet()) {
String originEndpoint = entry.getKey();
ConnectionManager connection = entry.getValue();
- // For safety reset current used replica.
- clientFactory.setCurrentConfigStoreClient(originEndpoint, originEndpoint);
AppConfigurationStoreMonitoring monitor = connection.getMonitoring();
@@ -94,7 +92,8 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
monitor.getRefreshInterval(), data, replicaLookUp, ctx),
eventData,
context,
- "configuration refresh check");
+ "configuration refresh check",
+ false);
if (result != null) {
return result;
}
@@ -113,7 +112,8 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
monitor.getFeatureFlagRefreshInterval(), data, replicaLookUp, ctx),
eventData,
context,
- "feature flag refresh check");
+ "feature flag refresh check",
+ true);
if (result != null) {
return result;
}
@@ -139,6 +139,7 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
* @param eventData the refresh event data to update
* @param context the operation context
* @param checkType description of the check type for logging (e.g., "configuration refresh check")
+ * @param useLastActive whether to reuse the last active client
* @return the eventData if refresh is needed, null otherwise
*/
private RefreshEventData executeRefreshWithRetry(
@@ -147,14 +148,14 @@ private RefreshEventData executeRefreshWithRetry(
RefreshOperation operation,
RefreshEventData eventData,
Context context,
- String checkType) {
- AppConfigurationReplicaClient client = clientFactory.getNextActiveClient(originEndpoint, false);
+ String checkType,
+ boolean useLastActive) {
+ AppConfigurationReplicaClient client = clientFactory.getNextActiveClient(originEndpoint, useLastActive);
while (client != null) {
try {
operation.execute(client, eventData, context);
if (eventData.getDoRefresh()) {
- clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint());
return eventData;
}
// If check didn't throw an error, other clients don't need to be checked.
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java
index 8bd7792707e9..dd64ede688db 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java
@@ -107,16 +107,6 @@ String findOriginForEndpoint(String endpoint) {
return endpoint;
}
- /**
- * Sets the current active replica for a configuration store.
- *
- * @param originEndpoint the origin configuration store endpoint
- * @param replicaEndpoint the replica endpoint that was successfully connected to
- */
- void setCurrentConfigStoreClient(String originEndpoint, String replicaEndpoint) {
- CONNECTIONS.get(originEndpoint).setCurrentClient(replicaEndpoint);
- }
-
/**
* Updates the sync token for a specific replica endpoint.
*
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
index 051600d02241..4b7f6f2c1ca1 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
@@ -34,9 +34,6 @@ class ConnectionManager {
/** Map of auto-discovered failover clients, keyed by endpoint URL. */
private final Map autoFailoverClients;
- /** Currently active replica endpoint being used for requests. */
- private String currentReplica;
-
/** Current health status of the App Configuration store connection. */
private AppConfigurationStoreHealth health;
@@ -67,7 +64,6 @@ class ConnectionManager {
this.configStore = configStore;
this.originEndpoint = configStore.getEndpoint();
this.health = AppConfigurationStoreHealth.NOT_LOADED;
- this.currentReplica = configStore.getEndpoint();
this.autoFailoverClients = new HashMap<>();
this.replicaLookUp = replicaLookUp;
this.activeClients = new ArrayList<>();
@@ -83,15 +79,6 @@ AppConfigurationStoreHealth getHealth() {
return this.health;
}
- /**
- * Sets the current active replica endpoint for client routing.
- *
- * @param replicaEndpoint the endpoint URL to set as current; may be null to reset to primary endpoint
- */
- void setCurrentClient(String replicaEndpoint) {
- this.currentReplica = replicaEndpoint;
- }
-
/**
* Retrieves the primary (origin) endpoint URL for the App Configuration store.
*
@@ -108,10 +95,7 @@ String getMainEndpoint() {
* @return the next active AppConfigurationReplicaClient
*/
AppConfigurationReplicaClient getNextActiveClient(boolean useLastActive) {
- if (activeClients.isEmpty()) {
- lastActiveClient = "";
- return null;
- } else if (useLastActive) {
+ if (useLastActive) {
List clients = getAvailableClients();
for (AppConfigurationReplicaClient client: clients) {
if (client.getEndpoint().equals(lastActiveClient)) {
@@ -119,6 +103,10 @@ AppConfigurationReplicaClient getNextActiveClient(boolean useLastActive) {
}
}
}
+ if (activeClients.isEmpty()) {
+ lastActiveClient = "";
+ return null;
+ }
if (!configStore.isLoadBalancingEnabled()) {
if (!activeClients.isEmpty()) {
@@ -127,6 +115,7 @@ AppConfigurationReplicaClient getNextActiveClient(boolean useLastActive) {
return null;
}
+ // Remove the current client from the list. The list will be rebuilt and rotated on the next refresh cycle by findActiveClients().
AppConfigurationReplicaClient nextClient = activeClients.remove(0);
lastActiveClient = nextClient.getEndpoint();
return nextClient;
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
index 6af627e6afad..ebe6ea4d1fa1 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
@@ -248,7 +248,6 @@ public void refreshStoresCheckSettingsTestNotEnabled(TestInfo testInfo) {
Duration.ofMinutes(10),
(long) 60, replicaLookUpMock);
assertFalse(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
}
@@ -271,7 +270,6 @@ public void refreshStoresCheckSettingsTestNotLoaded(TestInfo testInfo) {
RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock,
Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
assertFalse(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
}
@@ -292,7 +290,6 @@ public void refreshStoresCheckSettingsTestNotRefreshTime(TestInfo testInfo) {
Duration.ofMinutes(10),
(long) 60, replicaLookUpMock);
assertFalse(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
}
@@ -316,7 +313,6 @@ public void refreshStoresCheckSettingsTestFailedRequest(TestInfo testInfo) {
(long) 60, replicaLookUpMock);
assertFalse(eventData.getDoRefresh());
ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class);
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
captorParam.capture());
assertEquals(newState, StateHolder.getState(endpoint));
@@ -346,7 +342,6 @@ public void refreshStoresCheckSettingsTestRefreshTimeNoChange(TestInfo testInfo)
Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
assertEquals(newState, StateHolder.getState(endpoint));
assertFalse(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
}
@@ -380,7 +375,6 @@ public void refreshStoresPushRefreshEnabledPrimary(TestInfo testInfo) {
assertEquals(newState, StateHolder.getState(endpoint));
assertFalse(eventData.getDoRefresh());
ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class);
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
captorParam.capture());
Context testContext = captorParam.getValue();
@@ -417,7 +411,6 @@ public void refreshStoresPushRefreshEnabledSecondary(TestInfo testInfo) {
assertEquals(newState, StateHolder.getState(endpoint));
assertFalse(eventData.getDoRefresh());
ArgumentCaptor captorParam = ArgumentCaptor.forClass(Context.class);
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
captorParam.capture());
Context testContext = captorParam.getValue();
@@ -452,7 +445,6 @@ public void refreshStoresCheckSettingsTestTriggerRefresh(TestInfo testInfo) {
Duration.ofMinutes(10),
(long) 60, replicaLookUpMock);
assertTrue(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(1)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
verify(currentStateMock, times(1)).updateStateRefresh(Mockito.any(), Mockito.any());
@@ -476,7 +468,6 @@ public void refreshStoresCheckFeatureFlagTestNotLoaded(TestInfo testInfo) {
Duration.ofMinutes(10),
(long) 60, replicaLookUpMock);
assertFalse(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
}
@@ -499,7 +490,6 @@ public void refreshStoresCheckFeatureFlagTestNotRefreshTime(TestInfo testInfo) {
Duration.ofMinutes(10),
(long) 60, replicaLookUpMock);
assertFalse(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
}
@@ -529,7 +519,6 @@ public void refreshStoresCheckFeatureFlagTestNoChange(TestInfo testInfo) {
RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock,
Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
assertFalse(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
verify(currentStateMock, times(1)).updateFeatureFlagStateRefresh(Mockito.any(), Mockito.any());
@@ -558,7 +547,6 @@ public void refreshStoresCheckFeatureFlagTestTriggerRefresh(TestInfo testInfo) {
RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock,
Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
assertTrue(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
}
@@ -631,7 +619,6 @@ public void refreshAllWithWatchedConfigurationSettingsTest(TestInfo testInfo) {
clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock);
assertTrue(eventData.getDoRefresh());
- verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint));
// Verify checkWatchKeys is called (watched configuration settings path)
verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class),
Mockito.any(Context.class));
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java
index 787138a67a57..ebc0d979c744 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java
@@ -277,7 +277,7 @@ public void getNextActiveClientWithLoadBalancingTest() {
throw new RuntimeException("Test verification failed", e);
}
- // activeClients list should now only have the second client
+ // activeClients list should now contain only the second client
try {
java.lang.reflect.Field activeClientsField = ConnectionManager.class.getDeclaredField("activeClients");
activeClientsField.setAccessible(true);
From 8f31c51ec1e67d2975683a2a531ca9e3e120a660 Mon Sep 17 00:00:00 2001
From: Eldert Grootenboer
Date: Sat, 28 Feb 2026 10:13:31 +0800
Subject: [PATCH 05/22] fix(spring-messaging-servicebus): evict stale
processors from cache (#48062)
* fix(spring-messaging-servicebus): evict stale processors from cache (#48030)
* chore: revert CHANGELOG entry per reviewer feedback
* Update sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* fix: separate try-catch for listener notification and stale processor close
Ensures stale.close() always executes even if listener notification
throws, preventing a potential resource leak.
* refactor: extract buildProcessorName to local variable in stale eviction block
Avoids recomputing buildProcessorName(key) multiple times and ensures
consistent value across debug log, listener callback, and warn logs.
* test: add mock-based tests for running-to-not-running processor transition
- Add testRunningProcessorReturnedFromCache: verifies running processor
stays in cache without eviction
- Add testProcessorEvictedAfterTransitionToNotRunning: verifies stale
(non-running) processor is evicted, closed, and replaced
- Update comments on existing stale tests to clarify they test
never-started processors; reference new mock tests for transition
- Add reflection helper getProcessorMap() for injecting mocks into cache
---------
Co-authored-by: Eldert Grootenboer (from Dev Box)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 1c16e1794a00e47dce62dedf5ad3d08e664dde72)
---
sdk/spring/CHANGELOG.md | 10 +++
...ltServiceBusNamespaceProcessorFactory.java | 24 ++++++
...viceBusNamespaceProcessorFactoryTests.java | 85 +++++++++++++++++++
3 files changed, 119 insertions(+)
diff --git a/sdk/spring/CHANGELOG.md b/sdk/spring/CHANGELOG.md
index 3ee4233e8ead..01bd8f2715b1 100644
--- a/sdk/spring/CHANGELOG.md
+++ b/sdk/spring/CHANGELOG.md
@@ -8,11 +8,20 @@ This section includes changes in `spring-cloud-azure-appconfiguration-config` mo
- Fixed bug where connection string validation occurred even when `spring.cloud.azure.appconfiguration.enabled` is `false`. ([#47587](https://github.com/Azure/azure-sdk-for-java/issues/47587))
+### Spring Messaging Azure Service Bus
+
+This section includes changes in `spring-messaging-azure-servicebus` module.
+
+#### Bugs Fixed
+
+- Fixed `DefaultServiceBusNamespaceProcessorFactory` not removing closed/disposed `ServiceBusProcessorClient` instances from its internal cache, causing subsequent `createProcessor()` calls to return stale, non-functional processors. [#48030](https://github.com/Azure/azure-sdk-for-java/issues/48030)
+
## 6.2.0 (2026-03-25)
- This release is compatible with Spring Boot 3.5.0-3.5.8. (Note: 3.5.x (x>8) should be supported, but they aren't tested with this release.)
- This release is compatible with Spring Cloud 2025.0.0. (Note: 2025.0.x (x>0) should be supported, but they aren't tested with this release.)
### Spring Cloud Azure Autoconfigure
+
This section includes changes in `spring-cloud-azure-autoconfigure` module.
#### New Features
@@ -45,6 +54,7 @@ The ConnectionFactory type is determined by the following configuration properti
**Note:** `CachingConnectionFactory` and `JmsPoolConnectionFactory` will be used only when they exist in classpath.
### Spring Cloud Azure Docker Compose
+
This section includes changes in `spring-cloud-azure-docker-compose` module.
#### New Features
diff --git a/sdk/spring/spring-messaging-azure-servicebus/src/main/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactory.java b/sdk/spring/spring-messaging-azure-servicebus/src/main/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactory.java
index b59b5b90c960..096aa3570fbe 100644
--- a/sdk/spring/spring-messaging-azure-servicebus/src/main/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactory.java
+++ b/sdk/spring/spring-messaging-azure-servicebus/src/main/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactory.java
@@ -162,6 +162,30 @@ private ServiceBusProcessorClient doCreateProcessor(String name,
@Nullable ProcessorProperties properties) {
ConsumerIdentifier key = new ConsumerIdentifier(name, subscription);
+ // If a cached processor is no longer running (e.g., closed due to a non-transient error,
+ // or stopped), evict it atomically so a fresh one is created below. The atomic
+ // remove(key, value) ensures that if another thread already replaced the stale entry,
+ // we do not accidentally remove the new processor.
+ ServiceBusProcessorClient stale = processorMap.get(key);
+ if (stale != null && !stale.isRunning()) {
+ if (processorMap.remove(key, stale)) {
+ String processorName = buildProcessorName(key);
+ LOGGER.debug("Removing stale (non-running) processor for '{}'.", processorName);
+ try {
+ listeners.forEach(l -> l.processorRemoved(processorName, stale));
+ } catch (Exception ex) {
+ LOGGER.warn("Listener notification failed while removing stale processor for '{}'.",
+ processorName, ex);
+ }
+ try {
+ stale.close();
+ } catch (Exception ex) {
+ LOGGER.warn("Failed to close stale Service Bus processor client for '{}'.",
+ processorName, ex);
+ }
+ }
+ }
+
return processorMap.computeIfAbsent(key, k -> {
ProcessorPropertiesParentMerger propertiesMerger = new ProcessorPropertiesParentMerger();
ProcessorProperties processorProperties = propertiesMerger.merge(properties, this.namespaceProperties);
diff --git a/sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java b/sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java
index 1b349f2aa5f3..236e11a8ab2f 100644
--- a/sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java
+++ b/sdk/spring/spring-messaging-azure-servicebus/src/test/java/com/azure/spring/messaging/servicebus/core/DefaultServiceBusNamespaceProcessorFactoryTests.java
@@ -6,15 +6,24 @@
import com.azure.messaging.servicebus.ServiceBusProcessorClient;
import com.azure.spring.cloud.service.servicebus.consumer.ServiceBusErrorHandler;
import com.azure.spring.cloud.service.servicebus.consumer.ServiceBusRecordMessageListener;
+import com.azure.spring.messaging.ConsumerIdentifier;
import com.azure.spring.messaging.servicebus.core.properties.NamespaceProperties;
import com.azure.spring.messaging.servicebus.core.properties.ServiceBusContainerProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.lang.reflect.Field;
+import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
public class DefaultServiceBusNamespaceProcessorFactoryTests {
private ServiceBusProcessorFactory processorFactory;
@@ -70,10 +79,79 @@ void testCreateServiceBusProcessorClientQueueTwice() {
@Test
void testCreateServiceBusProcessorClientTopicTwice() {
+ // The first processor is created but never started (isRunning() == false).
+ // The factory treats a non-running cached processor as stale: it evicts it and
+ // creates a fresh one on the next createProcessor call for the same key.
ServiceBusProcessorClient client = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler);
assertNotNull(client);
processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler);
+ assertEquals(2, topicProcessorAddedTimes);
+ }
+
+ @Test
+ void testStaleProcessorReplacedAfterClose() {
+ // End-to-end test with a real processor: create, close, re-create.
+ // Note: the processor is never started, so isRunning() is already false before close().
+ // See testProcessorEvictedAfterTransitionToNotRunning for a mock-based test that
+ // simulates the running → not-running transition.
+ ServiceBusProcessorClient first = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler);
+ assertNotNull(first);
+ assertEquals(1, topicProcessorAddedTimes);
+
+ first.close();
+
+ ServiceBusProcessorClient second = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler);
+ assertNotNull(second);
+ assertNotSame(first, second);
+ assertEquals(2, topicProcessorAddedTimes);
+ }
+
+ @Test
+ void testStaleQueueProcessorReplacedAfterClose() {
+ // End-to-end test with a real queue processor: create, close, re-create.
+ // See testProcessorEvictedAfterTransitionToNotRunning for a mock-based test that
+ // simulates the running → not-running transition.
+ ServiceBusProcessorClient first = processorFactory.createProcessor(entityName, this.listener, errorHandler);
+ assertNotNull(first);
+ assertEquals(1, queueProcessorAddedTimes);
+
+ first.close();
+
+ ServiceBusProcessorClient second = processorFactory.createProcessor(entityName, this.listener, errorHandler);
+ assertNotNull(second);
+ assertNotSame(first, second);
+ assertEquals(2, queueProcessorAddedTimes);
+ }
+
+ @Test
+ void testRunningProcessorReturnedFromCache() throws Exception {
+ // A processor that reports isRunning() == true must NOT be evicted from the cache.
+ ServiceBusProcessorClient runningMock = mock(ServiceBusProcessorClient.class);
+ when(runningMock.isRunning()).thenReturn(true);
+
+ ConsumerIdentifier key = new ConsumerIdentifier(entityName, subscription);
+ getProcessorMap().put(key, runningMock);
+
+ ServiceBusProcessorClient result = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler);
+ assertSame(runningMock, result);
+ verify(runningMock, never()).close();
+ }
+
+ @Test
+ void testProcessorEvictedAfterTransitionToNotRunning() throws Exception {
+ // Simulate a processor that was running and then transitioned to not-running
+ // (e.g., closed by the SDK due to a non-transient error, or stopped).
+ ServiceBusProcessorClient staleMock = mock(ServiceBusProcessorClient.class);
+ when(staleMock.isRunning()).thenReturn(false);
+
+ ConsumerIdentifier key = new ConsumerIdentifier(entityName, subscription);
+ getProcessorMap().put(key, staleMock);
+
+ ServiceBusProcessorClient result = processorFactory.createProcessor(entityName, subscription, this.listener, errorHandler);
+ assertNotSame(staleMock, result);
+ assertNotNull(result);
+ verify(staleMock).close();
assertEquals(1, topicProcessorAddedTimes);
}
@@ -170,4 +248,11 @@ void dedicatedCustomizerShouldBeCalledOnlyWhenMatchingClientsCreated() {
assertEquals(1, sessionClientCalledTimes.get());
}
+ @SuppressWarnings("unchecked")
+ private Map getProcessorMap() throws Exception {
+ Field field = DefaultServiceBusNamespaceProcessorFactory.class.getDeclaredField("processorMap");
+ field.setAccessible(true);
+ return (Map) field.get(processorFactory);
+ }
+
}
From 0c387c5a0b4a729a9481e75ce2f301ee9dfdd90f Mon Sep 17 00:00:00 2001
From: Copilot <198982749+copilot@users.noreply.github.com>
Date: Tue, 3 Mar 2026 17:17:25 +0800
Subject: [PATCH 06/22] Fix: KeyVaultJcaProvider registered at highest JCA
priority breaks mTLS with standard keystores (#48198)
* Initial plan
* Fix: KeyVaultJcaProvider registered at highest priority breaks mTLS with standard keystores
Co-authored-by: rujche <171773178+rujche@users.noreply.github.com>
* Revert spring-cloud-azure-autoconfigure/CHANGELOG.md - keep aligned with other versions
Co-authored-by: rujche <171773178+rujche@users.noreply.github.com>
* Update CHANGELOG issue link to #48183
Co-authored-by: rujche <171773178+rujche@users.noreply.github.com>
* Apply PR review suggestions: remove unused param and use PROVIDER_NAME constant
Co-authored-by: rujche <171773178+rujche@users.noreply.github.com>
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rujche <171773178+rujche@users.noreply.github.com>
(cherry picked from commit 7f9adaf992cdce55cbb54ac4a012460526c490a6)
---
sdk/spring/CHANGELOG.md | 6 +++
.../jca/AzureKeyVaultSslBundleRegistrar.java | 2 +-
.../AzureKeyVaultSslBundleRegistrarTests.java | 46 +++++++++++++++++++
3 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/sdk/spring/CHANGELOG.md b/sdk/spring/CHANGELOG.md
index 01bd8f2715b1..e18e1b2545fd 100644
--- a/sdk/spring/CHANGELOG.md
+++ b/sdk/spring/CHANGELOG.md
@@ -1,6 +1,12 @@
# Release History
## 6.3.0
+### Spring Cloud Azure Autoconfigure
+
+#### Bugs Fixed
+
+- Fixed `KeyVaultJcaProvider` being registered as the highest-priority JCA security provider, which overrides standard JCA services (`KeyManagerFactory.SunX509`, `Signature` algorithms) and breaks mTLS with standard keystores (JKS, PKCS12). The provider is now added at the end of the provider list, allowing JCA's delayed provider selection to route `KeyVaultPrivateKey` signing operations to the KeyVault implementations without interfering with standard SSL/TLS operations. [#48183](https://github.com/Azure/azure-sdk-for-java/issues/48183)
+
### Spring Cloud Azure Appconfiguration Config
This section includes changes in `spring-cloud-azure-appconfiguration-config` module.
diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrar.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrar.java
index 2a94664e3db6..10521f66f533 100644
--- a/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrar.java
+++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/main/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrar.java
@@ -137,7 +137,7 @@ private KeyStore initilizeKeyVaultKeyStore(String storeName,
configureJcaKeyStoreSystemProperties(jcaVaultProperties, keyStoreProperties, resourceLoader);
if (providerConfigured.compareAndSet(false, true)) {
Security.removeProvider(KeyVaultJcaProvider.PROVIDER_NAME);
- Security.insertProviderAt(new KeyVaultJcaProvider(), 1);
+ Security.addProvider(new KeyVaultJcaProvider());
}
KeyStore azureKeyVaultKeyStore;
try {
diff --git a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrarTests.java b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrarTests.java
index ba2539ab7c9c..2d1a99583046 100644
--- a/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrarTests.java
+++ b/sdk/spring/spring-cloud-azure-autoconfigure/src/test/java/com/azure/spring/cloud/autoconfigure/implementation/keyvault/jca/AzureKeyVaultSslBundleRegistrarTests.java
@@ -3,8 +3,10 @@
package com.azure.spring.cloud.autoconfigure.implementation.keyvault.jca;
+import com.azure.security.keyvault.jca.KeyVaultJcaProvider;
import com.azure.spring.cloud.autoconfigure.implementation.keyvault.jca.properties.AzureKeyVaultJcaProperties;
import com.azure.spring.cloud.autoconfigure.implementation.keyvault.jca.properties.AzureKeyVaultSslBundleProperties;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.parallel.Isolated;
@@ -19,6 +21,7 @@
import org.springframework.util.ClassUtils;
import java.security.KeyStore;
+import java.security.Security;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -33,6 +36,11 @@
@ExtendWith({OutputCaptureExtension.class})
class AzureKeyVaultSslBundleRegistrarTests {
+ @AfterEach
+ void cleanupProvider() {
+ Security.removeProvider(KeyVaultJcaProvider.PROVIDER_NAME);
+ }
+
@Test
void noJcaProviderOnClassPath(CapturedOutput capturedOutput) {
AzureKeyVaultSslBundleRegistrar registrar = new AzureKeyVaultSslBundleRegistrar(new AzureKeyVaultJcaProperties(), new AzureKeyVaultSslBundleProperties());
@@ -200,4 +208,42 @@ void registerMultipleSslBundles(CapturedOutput capturedOutput) {
});
}
}
+
+ @Test
+ void keyVaultProviderNotInsertedAtHighestPriority() {
+ AzureKeyVaultJcaProperties jcaProperties = new AzureKeyVaultJcaProperties();
+ AzureKeyVaultSslBundleProperties sslBundleProperties = new AzureKeyVaultSslBundleProperties();
+ AzureKeyVaultSslBundleRegistrar registrar = new AzureKeyVaultSslBundleRegistrar(jcaProperties, sslBundleProperties);
+ registrar.setResourceLoader(new DefaultResourceLoader());
+ SslBundleRegistry registry = Mockito.mock(SslBundleRegistry.class);
+
+ try (MockedStatic keyStoreMockedStatic = mockStatic(KeyStore.class)) {
+ KeyStore keyStore = Mockito.mock(KeyStore.class);
+ keyStoreMockedStatic.when(() -> KeyStore.getInstance(KeyVaultJcaProvider.PROVIDER_NAME)).thenReturn(keyStore);
+
+ String keyvaultName = "keyvault1";
+ AzureKeyVaultJcaProperties.JcaVaultProperties jcaVaultProperties = new AzureKeyVaultJcaProperties.JcaVaultProperties();
+ jcaVaultProperties.setEndpoint("https://test.vault.azure.net/");
+ jcaProperties.getVaults().put(keyvaultName, jcaVaultProperties);
+ AzureKeyVaultSslBundleProperties.KeyVaultSslBundleProperties bundleProperties = new AzureKeyVaultSslBundleProperties.KeyVaultSslBundleProperties();
+ bundleProperties.getTruststore().setKeyvaultRef(keyvaultName);
+ sslBundleProperties.getKeyvault().put("testBundle", bundleProperties);
+
+ registrar.registerBundles(registry);
+
+ // Verify the KeyVault JCA provider is NOT inserted at position 1 (highest priority),
+ // to avoid interfering with standard SSL/TLS operations and breaking mTLS.
+ java.security.Provider[] providers = Security.getProviders();
+ int providerPosition = -1;
+ for (int i = 0; i < providers.length; i++) {
+ if (KeyVaultJcaProvider.PROVIDER_NAME.equals(providers[i].getName())) {
+ providerPosition = i + 1; // 1-indexed position
+ break;
+ }
+ }
+ assertTrue(providerPosition > 1, "KeyVaultJcaProvider must not be inserted at position 1 "
+ + "to avoid overriding standard JCA services like KeyManagerFactory.SunX509 and Signature algorithms, "
+ + "which would break mTLS with standard keystores.");
+ }
+ }
}
From 5753f623d94f1cc485965571b904d7a5ec692f1f Mon Sep 17 00:00:00 2001
From: muyao
Date: Thu, 23 Apr 2026 14:55:35 +0800
Subject: [PATCH 07/22] update changelog
---
sdk/spring/CHANGELOG.md | 18 +++++++++++++++++-
.../CHANGELOG.md | 5 -----
2 files changed, 17 insertions(+), 6 deletions(-)
diff --git a/sdk/spring/CHANGELOG.md b/sdk/spring/CHANGELOG.md
index e18e1b2545fd..bf1a0a07af81 100644
--- a/sdk/spring/CHANGELOG.md
+++ b/sdk/spring/CHANGELOG.md
@@ -10,8 +10,14 @@
### Spring Cloud Azure Appconfiguration Config
This section includes changes in `spring-cloud-azure-appconfiguration-config` module.
-#### Bugs Fixed
+### Features Added
+- Added support for filtering configuration settings and feature flags by tags. Tags can be configured via `spring.cloud.azure.appconfiguration.stores[0].selects[0].tags-filter` for key-value settings and `spring.cloud.azure.appconfiguration.stores[0].feature-flags.selects[0].tags-filter` for feature flags. The value is a list of `tag=value` pairs (e.g., `["env=prod", "team=backend"]`) combined with AND logic. [#47985](https://github.com/Azure/azure-sdk-for-java/pull/47985)
+
+### Bugs Fixed
+
+- Fixed an issue where feature flag–based refresh did not work when load balancing was enabled with a single configuration store. Feature flag refresh now uses the same load-balanced client selection as configuration refresh, including the single-store scenario. [#48121](https://github.com/Azure/azure-sdk-for-java/pull/48121)
+- Fixed YAML configuration binding for `label-filter` by adding standard no-arg getter methods to `AppConfigurationKeyValueSelector` and `FeatureFlagKeyValueSelector`, enabling proper type resolution by Spring Boot's `@ConfigurationProperties` binder. [#47985](https://github.com/Azure/azure-sdk-for-java/pull/47985)
- Fixed bug where connection string validation occurred even when `spring.cloud.azure.appconfiguration.enabled` is `false`. ([#47587](https://github.com/Azure/azure-sdk-for-java/issues/47587))
### Spring Messaging Azure Service Bus
@@ -81,6 +87,16 @@ This section includes changes in `spring-cloud-azure-testcontainers` module.
This section includes changes in `azure-spring-data-cosmos` module.
Please refer to [azure-spring-data-cosmos/CHANGELOG.md](https://github.com/Azure/azure-sdk-for-java/blob/main/sdk/spring/azure-spring-data-cosmos/CHANGELOG.md#620-2026-03-25) for more details.
+## 7.1.0 (2026-03-11)
+
+### Spring Cloud Azure Autoconfigure
+
+This section includes changes in `spring-cloud-azure-autoconfigure` module.
+
+#### Bugs Fixed
+
+- Fixed `KeyVaultJcaProvider` being registered as the highest-priority JCA security provider, which overrides standard JCA services (`KeyManagerFactory.SunX509`, `Signature` algorithms) and breaks mTLS with standard keystores (JKS, PKCS12). The provider is now added at the end of the provider list, allowing JCA's delayed provider selection to route `KeyVaultPrivateKey` signing operations to the KeyVault implementations without interfering with standard SSL/TLS operations. [#48183](https://github.com/Azure/azure-sdk-for-java/issues/48183)
+
## 6.1.0 (2025-12-16)
- This release is compatible with Spring Boot 3.5.0-3.5.8. (Note: 3.5.x (x>8) should be supported, but they aren't tested with this release.)
- This release is compatible with Spring Cloud 2025.0.0. (Note: 2025.0.x (x>0) should be supported, but they aren't tested with this release.)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
index adb378c1babe..becc8268e1d3 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
@@ -4,15 +4,10 @@
### Features Added
-- Added support for filtering configuration settings and feature flags by tags. Tags can be configured via `spring.cloud.azure.appconfiguration.stores[0].selects[0].tags-filter` for key-value settings and `spring.cloud.azure.appconfiguration.stores[0].feature-flags.selects[0].tags-filter` for feature flags. The value is a list of `tag=value` pairs (e.g., `["env=prod", "team=backend"]`) combined with AND logic.
-
### Breaking Changes
### Bugs Fixed
-- Fixed an issue where feature flag–based refresh did not work when load balancing was enabled with a single configuration store. Feature flag refresh now uses the same load-balanced client selection as configuration refresh, including the single-store scenario.
-- Fixed YAML configuration binding for `label-filter` by adding standard no-arg getter methods to `AppConfigurationKeyValueSelector` and `FeatureFlagKeyValueSelector`, enabling proper type resolution by Spring Boot's `@ConfigurationProperties` binder.
-
### Other Changes
## 6.2.0 (2026-03-25)
From 6827b21e4698a74b421f3b51c18f4be51aa8759c Mon Sep 17 00:00:00 2001
From: Matthew Metcalf
Date: Mon, 23 Mar 2026 09:03:03 -0700
Subject: [PATCH 08/22] App Config Spring - Json content type fix (#48448)
* fixing semi colon support
* Update CHANGELOG.md
* bug fix
* review comments
* Apply suggestions from code review
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
* remove release notes
* Update JsonConfigurationParser.java
* Update JsonConfigurationParser.java
* Update JsonConfigurationParser.java
* Update JsonConfigurationParser.java
---------
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit c0c405a312f7fdc789d597998b5984dca8670893)
---
.../CHANGELOG.md | 2 +
.../JsonConfigurationParser.java | 34 +++++-----
.../JsonConfigurationParserTest.java | 62 ++++++++++++++++++-
3 files changed, 81 insertions(+), 17 deletions(-)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
index becc8268e1d3..05ed7f697e34 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
@@ -8,6 +8,8 @@
### Bugs Fixed
+- Fixes a bug where ';' was ignored in JSON content type checking.
+
### Other Changes
## 6.2.0 (2026-03-25)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java
index 824f111ae238..a7d1a00a0748 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParser.java
@@ -2,10 +2,8 @@
// Licensed under the MIT License.
package com.azure.spring.cloud.appconfiguration.config.implementation;
-import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
-import java.util.List;
import java.util.Map;
import org.springframework.boot.context.properties.source.InvalidConfigurationPropertyValueException;
@@ -23,25 +21,29 @@ final class JsonConfigurationParser {
private static final ObjectMapper MAPPER = JsonMapper.builder().enable(JsonReadFeature.ALLOW_JAVA_COMMENTS).build();
static boolean isJsonContentType(String contentType) {
- String acceptedMainType = "application";
- String acceptedSubType = "json";
-
if (!StringUtils.hasText(contentType)) {
return false;
}
- if (contentType.contains("/")) {
- String mainType = contentType.split("/")[0];
- String subType = contentType.split("/")[1];
+ contentType = contentType.strip().toLowerCase();
+ String mimeType = contentType.split(";")[0].strip();
- if (mainType.equalsIgnoreCase(acceptedMainType)) {
- if (subType.contains("+")) {
- List subtypes = Arrays.asList(subType.split("\\+"));
- return subtypes.contains(acceptedSubType);
- } else {
- return subType.equalsIgnoreCase(acceptedSubType);
- }
- }
+ String[] typeParts = mimeType.split("/");
+ if (typeParts.length != 2) {
+ return false;
+ }
+
+ String mainType = typeParts[0];
+ String subType = typeParts[1];
+
+ if (!"application".equals(mainType)) {
+ return false;
+ }
+
+ String[] subTypes = subType.split("\\+");
+ // Check if the last part (suffix) is "json"
+ if (subTypes.length > 0 && subTypes[subTypes.length - 1].equals("json")) {
+ return true;
}
return false;
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParserTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParserTest.java
index 51cc010bb88a..52641e43abc7 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParserTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/JsonConfigurationParserTest.java
@@ -25,17 +25,77 @@ public class JsonConfigurationParserTest {
@Test
public void isJsonContentType() {
+ // Basic valid JSON content types
assertTrue(JsonConfigurationParser.isJsonContentType("application/json"));
assertTrue(JsonConfigurationParser.isJsonContentType("application/api+json"));
- assertTrue(JsonConfigurationParser.isJsonContentType("application/json+activity"));
assertTrue(JsonConfigurationParser.isJsonContentType("application/vnd.xxxx+json"));
assertTrue(JsonConfigurationParser.isJsonContentType("application/vnd.microsoft.appconfig.document+json"));
+
+ // Invalid content types
assertFalse(JsonConfigurationParser.isJsonContentType("application"));
assertFalse(JsonConfigurationParser.isJsonContentType("app/json"));
assertFalse(JsonConfigurationParser.isJsonContentType("app/config"));
assertFalse(JsonConfigurationParser.isJsonContentType("application/config"));
assertFalse(JsonConfigurationParser.isJsonContentType(""));
assertFalse(JsonConfigurationParser.isJsonContentType(null));
+ assertFalse(JsonConfigurationParser.isJsonContentType("application/json+activity")); // activity is the suffix, not json
+ }
+
+ @Test
+ public void isJsonContentTypeWithParameters() {
+ // Content types with charset and other parameters
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/json; charset=utf-8"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/json;charset=utf-8"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/json ; charset=utf-8"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/vnd.api+json; charset=utf-8"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/json; charset=ISO-8859-1"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/json;boundary=something"));
+ }
+
+ @Test
+ public void isJsonContentTypeWithWhitespace() {
+ // Content types with various whitespace at the boundaries
+ assertTrue(JsonConfigurationParser.isJsonContentType(" application/json"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/json "));
+ assertTrue(JsonConfigurationParser.isJsonContentType(" application/json "));
+
+ // Internal whitespace around '/' or '+' is not allowed by RFC 7231/6838 token rules
+ assertFalse(JsonConfigurationParser.isJsonContentType("application / json"));
+ assertFalse(JsonConfigurationParser.isJsonContentType("application/vnd.api + json"));
+ }
+
+ @Test
+ public void isJsonContentTypeCaseInsensitive() {
+ // Case variations
+ assertTrue(JsonConfigurationParser.isJsonContentType("Application/Json"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("APPLICATION/JSON"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/JSON"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("Application/vnd.api+JSON"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/API+JSON"));
+ }
+
+ @Test
+ public void isJsonContentTypeEdgeCases() {
+ // Edge cases and boundary conditions
+ assertFalse(JsonConfigurationParser.isJsonContentType("application/")); // Empty subtype
+ assertFalse(JsonConfigurationParser.isJsonContentType("/json")); // Empty main type
+ assertFalse(JsonConfigurationParser.isJsonContentType("/")); // Both empty
+ assertFalse(JsonConfigurationParser.isJsonContentType("application/xml"));
+ assertFalse(JsonConfigurationParser.isJsonContentType("text/json")); // Wrong main type
+ assertFalse(JsonConfigurationParser.isJsonContentType("application/json+xml")); // json not as suffix
+ assertFalse(JsonConfigurationParser.isJsonContentType("application/xml+html")); // No json at all
+ assertFalse(JsonConfigurationParser.isJsonContentType(" ")); // Only whitespace
+ }
+
+ @Test
+ public void isJsonContentTypeComplexStructuredSyntax() {
+ // Complex structured syntax suffixes (RFC 6839)
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/problem+json"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/merge-patch+json"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/json-patch+json"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/ld+json")); // JSON-LD
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/hal+json"));
+ assertTrue(JsonConfigurationParser.isJsonContentType("application/vnd.geo+json"));
}
@Test
From daa30fc8adc4b0070c8b58d80eaf6f4c25b639fc Mon Sep 17 00:00:00 2001
From: muyao
Date: Thu, 23 Apr 2026 15:20:06 +0800
Subject: [PATCH 09/22] update changelog
---
sdk/spring/CHANGELOG.md | 3 ++-
.../spring-cloud-azure-appconfiguration-config/CHANGELOG.md | 2 --
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/sdk/spring/CHANGELOG.md b/sdk/spring/CHANGELOG.md
index bf1a0a07af81..3678b44d85a6 100644
--- a/sdk/spring/CHANGELOG.md
+++ b/sdk/spring/CHANGELOG.md
@@ -16,9 +16,10 @@ This section includes changes in `spring-cloud-azure-appconfiguration-config` mo
### Bugs Fixed
+- Fixes a bug where ';' was ignored in JSON content type checking. [#48448](https://github.com/Azure/azure-sdk-for-java/pull/48448)
- Fixed an issue where feature flag–based refresh did not work when load balancing was enabled with a single configuration store. Feature flag refresh now uses the same load-balanced client selection as configuration refresh, including the single-store scenario. [#48121](https://github.com/Azure/azure-sdk-for-java/pull/48121)
- Fixed YAML configuration binding for `label-filter` by adding standard no-arg getter methods to `AppConfigurationKeyValueSelector` and `FeatureFlagKeyValueSelector`, enabling proper type resolution by Spring Boot's `@ConfigurationProperties` binder. [#47985](https://github.com/Azure/azure-sdk-for-java/pull/47985)
-- Fixed bug where connection string validation occurred even when `spring.cloud.azure.appconfiguration.enabled` is `false`. ([#47587](https://github.com/Azure/azure-sdk-for-java/issues/47587))
+- Fixed bug where connection string validation occurred even when `spring.cloud.azure.appconfiguration.enabled` is `false`. [#47587](https://github.com/Azure/azure-sdk-for-java/issues/47587)
### Spring Messaging Azure Service Bus
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
index 05ed7f697e34..becc8268e1d3 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
@@ -8,8 +8,6 @@
### Bugs Fixed
-- Fixes a bug where ';' was ignored in JSON content type checking.
-
### Other Changes
## 6.2.0 (2026-03-25)
From f6463460cdee3c440e647f011a74d07c9bd08a39 Mon Sep 17 00:00:00 2001
From: Matthew Metcalf
Date: Tue, 14 Apr 2026 06:00:04 +0800
Subject: [PATCH 10/22] App Config - Startup retry (#47857)
* Refactor + Startup Retry
* Update AzureAppConfigDataLoader.java
* Adding Tests
* Updating readme, correct location
* Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* interval change
* fixes after merge
* Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update AzureAppConfigDataLoader.java
* review comments
* Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit 02491f7c7cbfe651e70929b13b9f0a1f9a07a7ff)
---
.../CHANGELOG.md | 2 +
.../AppConfigurationReplicaClientFactory.java | 10 +
.../AzureAppConfigDataLoader.java | 316 ++++++++++------
.../AzureAppConfigDataLocationResolver.java | 2 +-
.../AzureAppConfigDataResource.java | 17 +-
.../implementation/ConnectionManager.java | 45 +++
.../AppConfigurationProperties.java | 36 +-
.../AzureAppConfigDataLoaderTest.java | 347 +++++++++++-------
.../AzureAppConfigDataResourceTest.java | 22 +-
.../implementation/ConnectionManagerTest.java | 140 ++++++-
.../README.md | 9 +-
11 files changed, 661 insertions(+), 285 deletions(-)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
index becc8268e1d3..2aedb6c26781 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md
@@ -4,6 +4,8 @@
### Features Added
+- Added `startup-timeout` configuration option that enables automatic retry with backoff when transient failures occur during application startup. The provider will continue retrying until the timeout expires (default: 100 seconds).
+
### Breaking Changes
### Bugs Fixed
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java
index dd64ede688db..c2c7e7939048 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java
@@ -107,6 +107,16 @@ String findOriginForEndpoint(String endpoint) {
return endpoint;
}
+ /**
+ * Gets the duration in milliseconds until the next client becomes available for the specified store.
+ *
+ * @param originEndpoint the origin configuration store endpoint
+ * @return duration in milliseconds until next client is available, or 0 if one is available now
+ */
+ long getMillisUntilNextClientAvailable(String originEndpoint) {
+ return CONNECTIONS.get(originEndpoint).getMillisUntilNextClientAvailable();
+ }
+
/**
* Updates the sync token for a specific replica endpoint.
*
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
index 8d27d1a017cf..bf6c337d9d24 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java
@@ -61,7 +61,7 @@ public class AzureAppConfigDataLoader implements ConfigDataLoader featureFlagClient));
}
- // Reset telemetry usage for refresh
featureFlagClient.resetTelemetry();
+
List> sourceList = new ArrayList<>();
if (resource.isConfigStoreEnabled()) {
- replicaClientFactory = context.getBootstrapContext()
- .get(AppConfigurationReplicaClientFactory.class);
- keyVaultClientFactory = context.getBootstrapContext()
- .get(AppConfigurationKeyVaultClientFactory.class);
-
- boolean reloadFailed = false;
- boolean pushRefresh = false;
- Exception lastException = null;
- PushNotification notification = resource.getMonitoring().getPushNotification();
- if ((notification.getPrimaryToken() != null
- && StringUtils.hasText(notification.getPrimaryToken().getName()))
- || (notification.getSecondaryToken() != null
- && StringUtils.hasText(notification.getSecondaryToken().getName()))) {
- pushRefresh = true;
+ replicaClientFactory = context.getBootstrapContext().get(AppConfigurationReplicaClientFactory.class);
+ keyVaultClientFactory = context.getBootstrapContext().get(AppConfigurationKeyVaultClientFactory.class);
+
+ Exception loadException = loadConfiguration(sourceList);
+ if (loadException != null) {
+ if (resource.isRefresh()) {
+ logger.warn("Azure App Configuration failed during refresh for store: "
+ + resource.getEndpoint() + ". Continuing with existing configuration.");
+ } else {
+ logger.error("Azure App Configuration failed to load configuration during startup for store: "
+ + resource.getEndpoint() + ". Application cannot start without required configuration.");
+ failedToGeneratePropertySource(loadException);
+ }
}
- // Feature Management needs to be set in the last config store.
- requestContext = new Context("refresh", resource.isRefresh()).addData(PUSH_REFRESH, pushRefresh);
+ }
+
+ if (!featureFlagClient.getFeatureFlags().isEmpty()) {
+ sourceList.add(new AppConfigurationFeatureManagementPropertySource(featureFlagClient));
+ }
+ return new ConfigData(sourceList);
+ }
+
+ /**
+ * Loads configuration from Azure App Configuration with replica failover support.
+ *
+ * @param sourceList the list to populate with property sources
+ * @return the exception if loading failed, null on success
+ */
+ private Exception loadConfiguration(List> sourceList) {
+ PushNotification notification = resource.getMonitoring().getPushNotification();
+ boolean pushRefresh = (notification.getPrimaryToken() != null
+ && StringUtils.hasText(notification.getPrimaryToken().getName()))
+ || (notification.getSecondaryToken() != null
+ && StringUtils.hasText(notification.getSecondaryToken().getName()));
+
+ requestContext = new Context("refresh", resource.isRefresh()).addData(PUSH_REFRESH, pushRefresh);
+
+ // During refresh, attempt once without retry
+ if (resource.isRefresh()) {
+ replicaClientFactory.findActiveClients(resource.getEndpoint());
+ return attemptLoadFromClients(sourceList);
+ }
+
+ // During startup, retry with backoff until deadline
+ Instant startTime = Instant.now();
+ Instant deadline = startTime.plus(resource.getStartupTimeout());
+ Exception lastException = null;
+ int postFixedWindowAttempts = 0;
+ while (Instant.now().isBefore(deadline)) {
replicaClientFactory.findActiveClients(resource.getEndpoint());
+ lastException = attemptLoadFromClients(sourceList);
- AppConfigurationReplicaClient client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(),
- true);
+ if (lastException == null) {
+ return null; // Success
+ }
- while (client != null) {
- final AppConfigurationReplicaClient currentClient = client;
+ // All clients failed, use fixed backoff based on elapsed time
+ if (Instant.now().isBefore(deadline)) {
+ long elapsedSeconds = Instant.now().getEpochSecond() - startTime.getEpochSecond();
+ Long backoffSeconds = getBackoffDuration(elapsedSeconds);
- if (reloadFailed
- && !AppConfigurationRefreshUtil.refreshStoreCheck(currentClient,
- replicaClientFactory.findOriginForEndpoint(currentClient.getEndpoint()), requestContext)) {
- // This store doesn't have any changes where to refresh store did. Skipping to next client.
- client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), false);
- continue;
+ // If backoff is null, elapsed time exceeds fixed intervals - use exponential backoff
+ if (backoffSeconds == null) {
+ postFixedWindowAttempts++;
+ // Convert nanoseconds to seconds
+ backoffSeconds = BackoffTimeCalculator.calculateBackoff(postFixedWindowAttempts) / 1_000_000_000L;
}
- // Reverse in order to add Profile specific properties earlier, and last profile comes first
- try {
- sourceList.addAll(createSettings(currentClient));
- List featureFlags = createFeatureFlags(currentClient);
-
- logger.debug("PropertySource context.");
- AppConfigurationStoreMonitoring monitoring = resource.getMonitoring();
-
- storeState.setStateFeatureFlag(resource.getEndpoint(), featureFlags,
- monitoring.getFeatureFlagRefreshInterval());
-
- if (monitoring.isEnabled()) {
- // Check if refreshAll is enabled - if so, use watched configuration settings
- if (monitoring.getTriggers().size() == 0) {
- // Use watched configuration settings for refresh
- List watchedConfigurationSettingsList = getWatchedConfigurationSettings(
- currentClient);
- storeState.setState(resource.getEndpoint(), Collections.emptyList(),
- watchedConfigurationSettingsList, monitoring.getRefreshInterval());
- } else {
- // Use traditional watch key monitoring
- List watchKeysSettings = monitoring.getTriggers().stream()
- .map(trigger -> currentClient.getWatchKey(trigger.getKey(), trigger.getLabel(),
- requestContext))
- .toList();
-
- storeState.setState(resource.getEndpoint(), watchKeysSettings,
- monitoring.getRefreshInterval());
- }
+ // Don't wait longer than remaining time until deadline
+ long remainingSeconds = deadline.getEpochSecond() - Instant.now().getEpochSecond();
+ long waitSeconds = Math.min(backoffSeconds, remainingSeconds);
+
+ if (waitSeconds > 0) {
+ logger.debug("All replicas in backoff for store: " + resource.getEndpoint()
+ + ". Waiting " + waitSeconds + "s before retry (elapsed: " + elapsedSeconds + "s).");
+ try {
+ Thread.sleep(waitSeconds * 1000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return lastException;
}
- storeState.setLoadState(resource.getEndpoint(), true); // Success - configuration loaded, exit loop
- lastException = null;
- // Break out of the loop since we have successfully loaded configuration
- break;
- } catch (AppConfigurationStatusException e) {
- reloadFailed = true;
- replicaClientFactory.backoffClient(resource.getEndpoint(), currentClient.getEndpoint());
- lastException = e;
- // Log the specific replica failure with context
- AppConfigurationReplicaClient nextClient = replicaClientFactory
- .getNextActiveClient(resource.getEndpoint(), false);
- logReplicaFailure(currentClient, "status exception", nextClient != null, e);
- client = nextClient;
- } catch (Exception e) {
- // Store the exception to potentially use if all replicas fail
- lastException = e; // Log the specific replica failure with context
- replicaClientFactory.backoffClient(resource.getEndpoint(), currentClient.getEndpoint());
- AppConfigurationReplicaClient nextClient = replicaClientFactory
- .getNextActiveClient(resource.getEndpoint(), false);
- logReplicaFailure(currentClient, "exception", nextClient != null, e);
- client = nextClient;
}
- } // Check if all replicas failed
- if (lastException != null && !resource.isRefresh()) {
- // During startup, if all replicas failed, fail the application
- logger.error("Azure App Configuration failed to load configuration during startup for store: "
- + resource.getEndpoint() + ". Application cannot start without required configuration.");
- failedToGeneratePropertySource(lastException);
- } else if (lastException != null && resource.isRefresh()) {
- // During refresh, log warning but don't fail the application
- logger.warn("Azure App Configuration failed during refresh for store: "
- + resource.getEndpoint() + ". Continuing with existing configuration.");
}
}
+ return lastException;
+ }
- StateHolder.updateState(storeState);
- if (featureFlagClient.getFeatureFlags().size() > 0) {
- // Don't add feature flags if there are none, otherwise the local file can't load them.
- sourceList.add(new AppConfigurationFeatureManagementPropertySource(featureFlagClient));
+ /**
+ * Gets the backoff duration based on elapsed time since startup began.
+ *
+ * @param elapsedSeconds the number of seconds elapsed since startup began
+ * @return the backoff duration in seconds, or null if elapsed time exceeds all thresholds
+ */
+ private Long getBackoffDuration(long elapsedSeconds) {
+ for (int[] interval : STARTUP_BACKOFF_INTERVALS) {
+ if (elapsedSeconds < interval[0]) {
+ return (long) interval[1];
+ }
}
- return new ConfigData(sourceList);
+ // Return null when elapsed time exceeds all defined thresholds
+ return null;
+ }
+
+ /**
+ * Attempts to load configuration from available clients.
+ *
+ * @param sourceList the list to populate with property sources
+ * @return the exception if all clients failed, null on success
+ */
+ private Exception attemptLoadFromClients(List> sourceList) {
+ boolean reloadFailed = false;
+ Exception lastException = null;
+ AppConfigurationReplicaClient client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), true);
+ List> tempSourceList = new ArrayList<>();
+
+ while (client != null) {
+ tempSourceList.clear();
+ final AppConfigurationReplicaClient currentClient = client;
+
+ if (reloadFailed && !AppConfigurationRefreshUtil.refreshStoreCheck(currentClient,
+ replicaClientFactory.findOriginForEndpoint(currentClient.getEndpoint()), requestContext, storeState)) {
+ client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), false);
+ continue;
+ }
+
+ try {
+ tempSourceList.addAll(createSettings(currentClient));
+ List featureFlags = createFeatureFlags(currentClient);
+
+ AppConfigurationStoreMonitoring monitoring = resource.getMonitoring();
+
+ storeState.setStateFeatureFlag(resource.getEndpoint(), featureFlags,
+ monitoring.getFeatureFlagRefreshInterval());
+
+ if (monitoring.isEnabled()) {
+ setupMonitoringState(currentClient, monitoring);
+ }
+
+ storeState.setLoadState(resource.getEndpoint(), true);
+ sourceList.addAll(tempSourceList);
+ return null; // Success
+ } catch (AppConfigurationStatusException e) {
+ reloadFailed = true;
+ lastException = e;
+ client = handleReplicaFailure(currentClient, "status exception", e);
+ } catch (Exception e) {
+ lastException = e;
+ client = handleReplicaFailure(currentClient, "exception", e);
+ }
+ }
+
+ return lastException;
+ }
+
+ /**
+ * Sets up the monitoring state based on the configuration.
+ *
+ * @param client the replica client
+ * @param monitoring the monitoring configuration
+ * @throws Exception if setting up monitoring fails
+ */
+ private void setupMonitoringState(AppConfigurationReplicaClient client, AppConfigurationStoreMonitoring monitoring)
+ throws Exception {
+ if (monitoring.getTriggers().isEmpty()) {
+ // Use watched configuration settings for refresh
+ List watchedConfigurationSettingsList = getWatchedConfigurationSettings(
+ client);
+ storeState.setState(resource.getEndpoint(), Collections.emptyList(),
+ watchedConfigurationSettingsList, monitoring.getRefreshInterval());
+ return;
+ }
+ // Use traditional watch key monitoring
+ List watchKeysSettings = monitoring.getTriggers().stream()
+ .map(trigger -> client.getWatchKey(trigger.getKey(), trigger.getLabel(), requestContext))
+ .toList();
+
+ storeState.setState(resource.getEndpoint(), watchKeysSettings, monitoring.getRefreshInterval());
+ }
+
+ /**
+ * Handles a replica failure by backing off the client and getting the next available replica.
+ *
+ * @param client the failed client
+ * @param exceptionType a description of the exception type
+ * @param exception the exception that occurred
+ * @return the next available client, or null if none available
+ */
+ private AppConfigurationReplicaClient handleReplicaFailure(AppConfigurationReplicaClient client,
+ String exceptionType, Exception exception) {
+ replicaClientFactory.backoffClient(resource.getEndpoint(), client.getEndpoint());
+ AppConfigurationReplicaClient nextClient = replicaClientFactory.getNextActiveClient(resource.getEndpoint(),
+ false);
+
+ String scenario = resource.isRefresh() ? "refresh" : "startup";
+ String nextAction = nextClient != null ? "Trying next replica." : "No more replicas available.";
+ logger.warn("Azure App Configuration replica " + client.getEndpoint()
+ + " failed during " + scenario + " with " + exceptionType + ". "
+ + nextAction + " Store: " + resource.getEndpoint(), exception);
+
+ return nextClient;
}
/**
@@ -251,7 +361,7 @@ private List createSettings(AppConfigurationRepl
List profiles = resource.getProfiles().getActive();
for (AppConfigurationKeyValueSelector selectedKeys : selects) {
- AppConfigurationPropertySource propertySource = null;
+ AppConfigurationPropertySource propertySource;
if (StringUtils.hasText(selectedKeys.getSnapshotName())) {
propertySource = new AppConfigurationSnapshotPropertySource(
@@ -330,24 +440,6 @@ private List getWatchedConfigurationSettings(AppCo
return watchedConfigurationSettingsList;
}
- /**
- * Logs a replica failure with contextual information about the failure scenario and available replicas.
- *
- * @param client the replica client that failed
- * @param exceptionType a brief description of the exception type (e.g., "status exception", "exception")
- * @param hasMoreReplicas whether there are additional replicas available to try
- * @param exception the exception that caused the failure
- */
- private void logReplicaFailure(AppConfigurationReplicaClient client, String exceptionType,
- boolean hasMoreReplicas, Exception exception) {
- String scenario = resource.isRefresh() ? "refresh" : "startup";
- String nextAction = hasMoreReplicas ? "Trying next replica." : "No more replicas available.";
-
- logger.warn("Azure App Configuration replica " + client.getEndpoint()
- + " failed during " + scenario + " with " + exceptionType + ". "
- + nextAction + " Store: " + resource.getEndpoint(), exception);
- }
-
/**
* Introduces a delay before throwing exceptions during startup to prevent fast crash loops.
*/
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java
index 6a0ebe649ad1..75452808a5e9 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java
@@ -128,7 +128,7 @@ public List resolveProfileSpecific(
for (ConfigStore store : properties.getStores()) {
locations.add(
- new AzureAppConfigDataResource(properties.isEnabled(), store, profiles, START_UP.get(), properties.getRefreshInterval()));
+ new AzureAppConfigDataResource(properties.isEnabled(), store, profiles, START_UP.get(), properties.getRefreshInterval(), properties.getStartupTimeout()));
}
START_UP.set(false);
return locations;
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java
index ab1557a2a38b..dd5c5f8de978 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java
@@ -47,16 +47,21 @@ public class AzureAppConfigDataResource extends ConfigDataResource {
/** The interval at which configuration should be refreshed from the store. */
private final Duration refreshInterval;
+ /** The timeout duration for retry attempts during startup. */
+ private final Duration startupTimeout;
+
/**
* Constructs a new AzureAppConfigDataResource with the specified configuration store settings.
*
+ * @param appConfigEnabled true if Azure App Configuration is globally enabled
* @param configStore the configuration store settings containing endpoint, selectors, and other options
* @param profiles the Spring Boot profiles for conditional configuration loading
* @param startup true if this is a startup load operation, false if it is a refresh operation
* @param refreshInterval the interval at which configuration should be refreshed
+ * @param startupTimeout the timeout duration for retry attempts during startup
*/
AzureAppConfigDataResource(boolean appConfigEnabled, ConfigStore configStore, Profiles profiles, boolean startup,
- Duration refreshInterval) {
+ Duration refreshInterval, Duration startupTimeout) {
this.configStoreEnabled = appConfigEnabled && configStore.isEnabled();
this.endpoint = configStore.getEndpoint();
this.selects = configStore.getSelects();
@@ -66,6 +71,7 @@ public class AzureAppConfigDataResource extends ConfigDataResource {
this.profiles = profiles;
this.isRefresh = !startup;
this.refreshInterval = refreshInterval;
+ this.startupTimeout = startupTimeout;
}
/**
@@ -148,4 +154,13 @@ public boolean isRefresh() {
public Duration getRefreshInterval() {
return refreshInterval;
}
+
+ /**
+ * Gets the timeout duration for retry attempts during startup.
+ *
+ * @return the startup timeout duration
+ */
+ public Duration getStartupTimeout() {
+ return startupTimeout;
+ }
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
index 4b7f6f2c1ca1..de1396223925 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
@@ -231,6 +231,51 @@ void backoffClient(String endpoint) {
autoFailoverClients.get(endpoint).updateBackoffEndTime(Instant.now().plusNanos(backoffTime));
}
+ /**
+ * Gets the duration in milliseconds until the next client becomes available (exits backoff).
+ * Returns 0 if a client is already available, or the minimum wait time if all clients are in backoff.
+ *
+ * @return duration in milliseconds until next client is available, or 0 if one is available now
+ */
+ long getMillisUntilNextClientAvailable() {
+ Instant now = Instant.now();
+ Instant earliestAvailable = Instant.MAX;
+
+ // Check configured clients
+ if (clients != null) {
+ for (AppConfigurationReplicaClient client : clients) {
+ Instant backoffEnd = client.getBackoffEndTime();
+ if (!backoffEnd.isAfter(now)) {
+ return 0; // Client available now
+ }
+ if (backoffEnd.isBefore(earliestAvailable)) {
+ earliestAvailable = backoffEnd;
+ }
+ }
+ }
+
+ // Check auto-failover clients
+ for (AppConfigurationReplicaClient client : autoFailoverClients.values()) {
+ Instant backoffEnd = client.getBackoffEndTime();
+ if (!backoffEnd.isAfter(now)) {
+ return 0; // Client available now
+ }
+ if (backoffEnd.isBefore(earliestAvailable)) {
+ earliestAvailable = backoffEnd;
+ }
+ }
+
+ // If no clients were found or no backoff times were set, avoid calling toEpochMilli on Instant.MAX.
+ if (Instant.MAX.equals(earliestAvailable)) {
+ // No clients are currently tracked; treat as no wait required.
+ return 0L;
+ }
+
+ long millisUntilNext = earliestAvailable.toEpochMilli() - now.toEpochMilli();
+ // Ensure we never return a negative duration, even in the presence of clock skew.
+ return Math.max(millisUntilNext, 0L);
+ }
+
/**
* Updates the synchronization token for the specified client endpoint.
*
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
index 37578b33d7f3..5745e5b5b37b 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
@@ -37,9 +37,12 @@ public class AppConfigurationProperties {
private Duration refreshInterval;
/**
- * Returns whether Azure App Configuration is enabled.
- *
- * @return {@code true} if enabled, {@code false} otherwise
+ * The timeout duration for retry attempts during startup.
+ */
+ private Duration startupTimeout = Duration.ofSeconds(100);
+
+ /**
+ * @return the enabled
*/
public boolean isEnabled() {
return enabled;
@@ -91,10 +94,22 @@ public void setRefreshInterval(Duration refreshInterval) {
}
/**
- * Validates that at least one store is configured with a valid endpoint or
- * connection string, and that no duplicate endpoints exist.
- *
- * @throws IllegalArgumentException if validation fails or duplicate endpoints are found
+ * @return the startupTimeout
+ */
+ public Duration getStartupTimeout() {
+ return startupTimeout;
+ }
+
+ /**
+ * @param startupTimeout the startupTimeout to set
+ */
+ public void setStartupTimeout(Duration startupTimeout) {
+ this.startupTimeout = startupTimeout;
+ }
+
+ /**
+ * Validates at least one store is configured for use, and that they are valid.
+ * @throws IllegalArgumentException when duplicate endpoints are configured
*/
@PostConstruct
public void validateAndInit() {
@@ -129,5 +144,12 @@ public void validateAndInit() {
if (refreshInterval != null) {
Assert.isTrue(refreshInterval.getSeconds() >= 1, "Minimum refresh interval time is 1 Second.");
}
+ if (startupTimeout == null) {
+ throw new IllegalArgumentException("startupTimeout cannot be null.");
+ }
+ if (startupTimeout.compareTo(Duration.ofSeconds(30)) < 0
+ || startupTimeout.compareTo(Duration.ofSeconds(600)) > 0) {
+ throw new IllegalArgumentException("startupTimeout must be between 30 and 600 seconds.");
+ }
}
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java
index e12c9b57d7db..545921567228 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java
@@ -4,29 +4,35 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeast;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import java.io.IOException;
import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;
import org.mockito.MockitoSession;
import org.mockito.quality.Strictness;
+import org.springframework.boot.bootstrap.ConfigurableBootstrapContext;
+import org.springframework.boot.context.config.ConfigData;
+import org.springframework.boot.context.config.ConfigDataLoaderContext;
import org.springframework.boot.context.config.Profiles;
+import org.springframework.boot.logging.DeferredLog;
+import org.springframework.boot.logging.DeferredLogFactory;
-import com.azure.core.util.Context;
-import com.azure.data.appconfiguration.models.SettingSelector;
-import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreTrigger;
@@ -39,10 +45,27 @@ public class AzureAppConfigDataLoaderTest {
private AppConfigurationReplicaClient clientMock;
@Mock
- private WatchedConfigurationSettings watchedConfigurationSettingsMock;
+ private AppConfigurationReplicaClientFactory replicaClientFactoryMock;
+
+ @Mock
+ private AppConfigurationKeyVaultClientFactory keyVaultClientFactoryMock;
+
+ @Mock
+ private ConfigDataLoaderContext configDataLoaderContextMock;
+
+ @Mock
+ private ConfigurableBootstrapContext bootstrapContextMock;
+
+ @Mock
+ private DeferredLogFactory logFactoryMock;
+
+ @Mock
+ private StateHolder stateHolderMock;
private AzureAppConfigDataResource resource;
+ private AzureAppConfigDataResource refreshResource;
+
private ConfigStore configStore;
private MockitoSession session;
@@ -71,7 +94,20 @@ public void setup() {
Profiles profiles = Mockito.mock(Profiles.class);
lenient().when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER));
- resource = new AzureAppConfigDataResource(true, configStore, profiles, false, Duration.ofMinutes(1));
+ // Startup resource (isRefresh = false)
+ resource = new AzureAppConfigDataResource(true, configStore, profiles, true, Duration.ofMinutes(1), Duration.ofSeconds(30));
+ // Refresh resource (isRefresh = true)
+ refreshResource = new AzureAppConfigDataResource(true, configStore, profiles, false, Duration.ofMinutes(1), Duration.ofSeconds(30));
+
+ // Setup common mocks for ConfigDataLoaderContext
+ lenient().when(configDataLoaderContextMock.getBootstrapContext()).thenReturn(bootstrapContextMock);
+ lenient().when(bootstrapContextMock.isRegistered(FeatureFlagClient.class)).thenReturn(false);
+ lenient().when(bootstrapContextMock.get(AppConfigurationReplicaClientFactory.class))
+ .thenReturn(replicaClientFactoryMock);
+ lenient().when(bootstrapContextMock.get(AppConfigurationKeyVaultClientFactory.class))
+ .thenReturn(keyVaultClientFactoryMock);
+ lenient().when(bootstrapContextMock.get(StateHolder.class)).thenReturn(stateHolderMock);
+ lenient().when(logFactoryMock.getLog(any(Class.class))).thenReturn(new DeferredLog());
}
@AfterEach
@@ -81,187 +117,222 @@ public void cleanup() throws Exception {
}
@Test
- public void createWatchedConfigurationSettingsWithSingleSelectorTest() throws Exception {
+ public void loadSucceedsWhenNoClientsAvailableTest() throws IOException {
// Setup selector
AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
selector.setKeyFilter(KEY_FILTER);
selector.setLabelFilter(LABEL_FILTER);
configStore.getSelects().add(selector);
- // Setup mocks
- when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
- .thenReturn(watchedConfigurationSettingsMock);
- // Use reflection to test the private method
- AzureAppConfigDataLoader loader = createLoader();
- List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
+ // Setup mocks - no clients available
+ when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null);
+
+ // Test using public load() method
+ AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock);
+ ConfigData result = loader.load(configDataLoaderContextMock, resource);
- // Verify
+ // Verify - returns empty ConfigData when no clients available
assertNotNull(result);
- assertEquals(1, result.size());
+ verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT);
+ verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true));
+ }
+
+ @Test
+ public void refreshAllDisabledUsesWatchKeysTest() {
+ // Setup monitoring with refreshAll disabled (traditional watch keys)
+ AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
+ monitoring.setEnabled(true);
- ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class);
- verify(clientMock, times(1)).loadWatchedSettings(selectorCaptor.capture(), any(Context.class));
+ // Add trigger for traditional watch key
+ AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger();
+ trigger.setKey("sentinel");
+ trigger.setLabel("prod");
+ monitoring.setTriggers(List.of(trigger));
- SettingSelector capturedSelector = selectorCaptor.getValue();
- assertEquals(KEY_FILTER + "*", capturedSelector.getKeyFilter());
- assertEquals(LABEL_FILTER, capturedSelector.getLabelFilter());
+ configStore.setMonitoring(monitoring);
+
+ // Setup selector
+ AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
+ selector.setKeyFilter(KEY_FILTER);
+ selector.setLabelFilter(LABEL_FILTER);
+ configStore.getSelects().add(selector);
+
+ // Verify that when refreshAll is false, triggers are configured
+ assertEquals(1, monitoring.getTriggers().size());
+ assertEquals("sentinel", monitoring.getTriggers().get(0).getKey());
}
+ // Startup Retry Tests
+
@Test
- public void createWatchedConfigurationSettingsWithMultipleSelectorsTest() throws Exception {
- // Setup multiple selectors
- AppConfigurationKeyValueSelector selector1 = new AppConfigurationKeyValueSelector();
- selector1.setKeyFilter("/app1/*");
- selector1.setLabelFilter("dev");
- configStore.getSelects().add(selector1);
-
- AppConfigurationKeyValueSelector selector2 = new AppConfigurationKeyValueSelector();
- selector2.setKeyFilter("/app2/*");
- selector2.setLabelFilter("prod");
- configStore.getSelects().add(selector2);
-
- // Setup mocks
- when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
- .thenReturn(watchedConfigurationSettingsMock);
-
- // Test
- AzureAppConfigDataLoader loader = createLoader();
- List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
-
- // Verify - should create watched configuration settings for both selectors
+ public void startupSucceedsOnFirstAttemptTest() throws IOException {
+ // Setup selector
+ AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
+ selector.setKeyFilter(KEY_FILTER);
+ selector.setLabelFilter(LABEL_FILTER);
+ configStore.getSelects().add(selector);
+
+ // Setup mocks - no clients available (returns empty result)
+ when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null);
+
+ // Test using public load() method
+ AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock);
+ ConfigData result = loader.load(configDataLoaderContextMock, resource);
+
+ // Verify - success on first attempt, no retries needed
assertNotNull(result);
- assertEquals(2, result.size());
- verify(clientMock, times(2)).loadWatchedSettings(any(SettingSelector.class), any(Context.class));
+ verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT);
+ verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true));
}
@Test
- public void createWatchedConfigurationSettingsSkipsSnapshotsTest() throws Exception {
- // Setup selector with snapshot
- AppConfigurationKeyValueSelector snapshotSelector = new AppConfigurationKeyValueSelector();
- snapshotSelector.setSnapshotName("my-snapshot");
- configStore.getSelects().add(snapshotSelector);
-
- // Setup regular selector
- AppConfigurationKeyValueSelector regularSelector = new AppConfigurationKeyValueSelector();
- regularSelector.setKeyFilter(KEY_FILTER);
- regularSelector.setLabelFilter(LABEL_FILTER);
- configStore.getSelects().add(regularSelector);
-
- // Setup mocks
- when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
- .thenReturn(watchedConfigurationSettingsMock);
-
- // Test
- AzureAppConfigDataLoader loader = createLoader();
- List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
-
- // Verify - snapshot should be skipped, only regular selector should be processed
+ public void startupRetriesAfterClientFailureThenSucceedsTest() throws IOException {
+ // Use very short timeout so test runs quickly (minimal real sleeping)
+ Profiles profiles = Mockito.mock(Profiles.class);
+ when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER));
+
+ ConfigStore fastTimeoutStore = new ConfigStore();
+ fastTimeoutStore.setEndpoint(ENDPOINT);
+ fastTimeoutStore.setEnabled(true);
+ FeatureFlagStore featureFlagStore = new FeatureFlagStore();
+ featureFlagStore.setEnabled(false);
+ fastTimeoutStore.setFeatureFlags(featureFlagStore);
+
+ // Short timeout - test will retry once then succeed, sleeping ~5 seconds total
+ AzureAppConfigDataResource fastResource = new AzureAppConfigDataResource(
+ true, fastTimeoutStore, profiles, true, Duration.ofMinutes(1), Duration.ofSeconds(10));
+
+ // Setup selector
+ AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
+ selector.setKeyFilter(KEY_FILTER);
+ selector.setLabelFilter(LABEL_FILTER);
+ fastTimeoutStore.getSelects().add(selector);
+
+ // Setup mocks:
+ // - First getNextActiveClient(true) returns clientMock which will throw
+ // - First getNextActiveClient(false) returns null (no more replicas in first attempt)
+ // - Second getNextActiveClient(true) returns null (simulating success path)
+ when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true)))
+ .thenReturn(clientMock) // First attempt - will fail
+ .thenReturn(null); // Second attempt - no clients, treated as success
+ when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false)))
+ .thenReturn(null); // No more replicas
+ when(clientMock.getEndpoint()).thenReturn(ENDPOINT);
+ when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure"));
+
+ // Test using public load() method
+ AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock);
+ ConfigData result = loader.load(configDataLoaderContextMock, fastResource);
+
+ // Verify - retried after failure
assertNotNull(result);
- assertEquals(1, result.size());
- verify(clientMock, times(1)).loadWatchedSettings(any(SettingSelector.class), any(Context.class));
+ verify(replicaClientFactoryMock, atLeast(2)).findActiveClients(ENDPOINT);
}
@Test
- public void createWatchedConfigurationSettingsWithMultipleLabelsTest() throws Exception {
- // Setup selector with multiple labels
+ public void startupFailsAfterAllRetriesExhaustedTest() {
+ // Setup with a short timeout
+ Profiles profiles = Mockito.mock(Profiles.class);
+ lenient().when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER));
+
+ ConfigStore shortTimeoutStore = new ConfigStore();
+ shortTimeoutStore.setEndpoint(ENDPOINT);
+ shortTimeoutStore.setEnabled(true);
+ FeatureFlagStore featureFlagStore = new FeatureFlagStore();
+ featureFlagStore.setEnabled(false);
+ shortTimeoutStore.setFeatureFlags(featureFlagStore);
+
+ // Use 6-second timeout - enough for 1-2 retries (~5-10s total with backoff)
+ AzureAppConfigDataResource shortTimeoutResource = new AzureAppConfigDataResource(
+ true, shortTimeoutStore, profiles, true, Duration.ofMinutes(1), Duration.ofSeconds(6));
+
+ // Setup selector
AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
selector.setKeyFilter(KEY_FILTER);
- selector.setLabelFilter("dev,prod,test");
+ selector.setLabelFilter(LABEL_FILTER);
+ shortTimeoutStore.getSelects().add(selector);
+
+ // Setup mocks - client always fails (keep returning clientMock so it keeps trying and failing)
+ lenient().when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock);
+ lenient().when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null);
+ lenient().when(clientMock.getEndpoint()).thenReturn(ENDPOINT);
+ lenient().when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure"));
+
+ // Test using public load() method - should throw RuntimeException after retries exhausted
+ AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock);
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> loader.load(configDataLoaderContextMock, shortTimeoutResource));
+
+ // Verify - failure after retries exhausted
+ assertTrue(exception.getMessage().contains("Failed to generate property sources"));
+ verify(replicaClientFactoryMock, atLeast(1)).findActiveClients(ENDPOINT);
+ }
+
+ @Test
+ public void refreshOnlyAttemptsOnceOnFailureTest() throws IOException {
+ // Setup selector
+ AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
+ selector.setKeyFilter(KEY_FILTER);
+ selector.setLabelFilter(LABEL_FILTER);
configStore.getSelects().add(selector);
- // Setup mocks
- when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
- .thenReturn(watchedConfigurationSettingsMock);
- // Test
- AzureAppConfigDataLoader loader = createLoader();
- List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
+ // Setup mocks - client fails
+ lenient().when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock);
+ lenient().when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null);
+ lenient().when(clientMock.getEndpoint()).thenReturn(ENDPOINT);
+ lenient().when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure"));
- // Verify - should create watched configuration settings for each label
+ // Test with refresh resource (isRefresh = true) - should NOT throw, just warn
+ AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock);
+ ConfigData result = loader.load(configDataLoaderContextMock, refreshResource);
+
+ // Verify - only one findActiveClients call (no retry loop for refresh)
assertNotNull(result);
- assertEquals(3, result.size());
- verify(clientMock, times(3)).loadWatchedSettings(any(SettingSelector.class), any(Context.class));
+ verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT);
}
@Test
- public void refreshAllEnabledUsesWatchedConfigurationSettingsTest() throws Exception {
- // Setup monitoring with refreshAll enabled
- AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
- monitoring.setEnabled(true);
- configStore.setMonitoring(monitoring);
-
+ public void refreshSucceedsOnFirstAttemptTest() throws IOException {
// Setup selector
AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
selector.setKeyFilter(KEY_FILTER);
selector.setLabelFilter(LABEL_FILTER);
configStore.getSelects().add(selector);
- // Setup mocks
- when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class)))
- .thenReturn(watchedConfigurationSettingsMock);
+ // Setup mocks - no clients available (returns null = no-op success)
+ when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null);
- // Test - verify that watched configuration settings are created when refreshAll is enabled
- AzureAppConfigDataLoader loader = createLoader();
- List result = invokeGetWatchedConfigurationSettings(loader, clientMock);
+ // Test with refresh resource
+ AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock);
+ ConfigData result = loader.load(configDataLoaderContextMock, refreshResource);
- // Verify watched configuration settings were created
+ // Verify - success on first attempt
assertNotNull(result);
- assertEquals(1, result.size());
- verify(clientMock, times(1)).loadWatchedSettings(any(SettingSelector.class), any(Context.class));
+ verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT);
+ verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true));
}
@Test
- public void refreshAllDisabledUsesWatchKeysTest() throws Exception {
- // Setup monitoring with refreshAll disabled (traditional watch keys)
- AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
- monitoring.setEnabled(true);
-
- // Add trigger for traditional watch key
- AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger();
- trigger.setKey("sentinel");
- trigger.setLabel("prod");
- monitoring.setTriggers(List.of(trigger));
-
- configStore.setMonitoring(monitoring);
-
+ public void startupDoesNotRetryDuringRefreshTest() throws IOException {
// Setup selector
AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector();
selector.setKeyFilter(KEY_FILTER);
selector.setLabelFilter(LABEL_FILTER);
configStore.getSelects().add(selector);
- // Verify that when refreshAll is false, triggers are configured
- // The actual validation happens in validateAndInit which is called during load
- assertEquals(1, monitoring.getTriggers().size());
- assertEquals("sentinel", monitoring.getTriggers().get(0).getKey());
- }
+ // Setup mock - client throws exception
+ when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock);
+ when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null);
+ when(clientMock.getEndpoint()).thenReturn(ENDPOINT);
+ when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Test failure"));
- // Helper methods
+ // Test with refresh resource - should NOT throw, just warn and continue
+ AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock);
+ ConfigData result = loader.load(configDataLoaderContextMock, refreshResource);
- private AzureAppConfigDataLoader createLoader() {
- org.springframework.boot.logging.DeferredLogFactory logFactory = Mockito
- .mock(org.springframework.boot.logging.DeferredLogFactory.class);
- when(logFactory.getLog(any(Class.class))).thenReturn(new org.springframework.boot.logging.DeferredLog());
- return new AzureAppConfigDataLoader(logFactory);
- }
-
- private List invokeGetWatchedConfigurationSettings(
- AzureAppConfigDataLoader loader, AppConfigurationReplicaClient client) throws Exception {
- // Set resource field in the loader using reflection
- java.lang.reflect.Field resourceField = AzureAppConfigDataLoader.class.getDeclaredField("resource");
- resourceField.setAccessible(true);
- resourceField.set(loader, resource);
-
- // Set requestContext field (it can be null for this test)
- java.lang.reflect.Field requestContextField = AzureAppConfigDataLoader.class.getDeclaredField("requestContext");
- requestContextField.setAccessible(true);
- requestContextField.set(loader, Context.NONE);
-
- // Use reflection to invoke private method
- java.lang.reflect.Method method = AzureAppConfigDataLoader.class
- .getDeclaredMethod("getWatchedConfigurationSettings", AppConfigurationReplicaClient.class);
- method.setAccessible(true);
- @SuppressWarnings("unchecked")
- List result = (List) method.invoke(loader, client);
- return result;
+ // Verify - failure on first attempt, no retry
+ assertNotNull(result);
+ // Only one findActiveClients call (would be multiple in startup retry loop)
+ verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT);
}
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java
index 0de265607483..68d0686497fd 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java
@@ -2,14 +2,15 @@
// Licensed under the MIT License.
package com.azure.spring.cloud.appconfiguration.config.implementation;
-import java.time.Duration;
-import java.util.ArrayList;
-import java.util.List;
-
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -32,6 +33,7 @@ class AzureAppConfigDataResourceTest {
private static final String TEST_ENDPOINT = "https://test.azconfig.io";
private static final Duration TEST_REFRESH_INTERVAL = Duration.ofSeconds(30);
+ private static final Duration TEST_STARTUP_TIMEOUT = Duration.ofSeconds(100);
private ConfigStore configStore;
private AppConfigurationStoreMonitoring monitoring;
@@ -57,7 +59,7 @@ void testConfigStoreEnabledState(boolean appConfigEnabled, boolean configStoreEn
configStore.setEnabled(configStoreEnabled);
AzureAppConfigDataResource resource = new AzureAppConfigDataResource(
- appConfigEnabled, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL);
+ appConfigEnabled, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT);
assertEquals(expectedEnabled, resource.isConfigStoreEnabled(), description);
}
@@ -71,7 +73,7 @@ void testEnabledStateWithRefreshScenarios(boolean isRefresh, String scenarioDesc
configStore.setEnabled(true);
AzureAppConfigDataResource resource = new AzureAppConfigDataResource(
- true, configStore, mockProfiles, isRefresh, TEST_REFRESH_INTERVAL);
+ true, configStore, mockProfiles, isRefresh, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT);
assertTrue(resource.isConfigStoreEnabled(),
"Config store should be enabled in " + scenarioDescription + " when conditions are met");
@@ -91,14 +93,14 @@ void testAllPropertiesSetCorrectlyRegardlessOfEnabledState() {
configStore.setEnabled(true);
AzureAppConfigDataResource enabledResource = new AzureAppConfigDataResource(
- true, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL);
+ true, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT);
assertTrue(enabledResource.isConfigStoreEnabled());
assertAllPropertiesCorrect(enabledResource, trimKeyPrefixes, selects, featureFlagSelects, true);
configStore.setEnabled(false);
AzureAppConfigDataResource disabledResource = new AzureAppConfigDataResource(
- true, configStore, mockProfiles, true, TEST_REFRESH_INTERVAL);
+ true, configStore, mockProfiles, true, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT);
assertFalse(disabledResource.isConfigStoreEnabled());
assertAllPropertiesCorrect(disabledResource, trimKeyPrefixes, selects, featureFlagSelects, false);
@@ -123,13 +125,13 @@ private void assertAllPropertiesCorrect(AzureAppConfigDataResource resource,
void testNullRefreshIntervalHandling() {
configStore.setEnabled(true);
AzureAppConfigDataResource enabledResource = new AzureAppConfigDataResource(
- true, configStore, mockProfiles, false, null);
+ true, configStore, mockProfiles, false, null, TEST_STARTUP_TIMEOUT);
assertTrue(enabledResource.isConfigStoreEnabled());
assertNull(enabledResource.getRefreshInterval());
configStore.setEnabled(false);
AzureAppConfigDataResource disabledResource = new AzureAppConfigDataResource(
- false, configStore, mockProfiles, true, null);
+ false, configStore, mockProfiles, true, null, TEST_STARTUP_TIMEOUT);
assertFalse(disabledResource.isConfigStoreEnabled());
assertNull(disabledResource.getRefreshInterval());
assertFalse(disabledResource.isRefresh());
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java
index ebc0d979c744..89eb2b75448f 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java
@@ -2,27 +2,29 @@
// Licensed under the MIT License.
package com.azure.spring.cloud.appconfiguration.config.implementation;
+import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertSame;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.Mockito;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
import org.mockito.MockitoAnnotations;
import org.mockito.MockitoSession;
import org.mockito.quality.Strictness;
import com.azure.spring.cloud.appconfiguration.config.AppConfigurationStoreHealth;
-import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT;
import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore;
@@ -101,8 +103,8 @@ public void backoffTest() {
configStore = new ConfigStore();
List endpoints = new ArrayList<>();
- endpoints.add("https://fake.test.config.io");
- endpoints.add("https://fake.test.geo.config.io");
+ endpoints.add("fake.test.endpoint.one");
+ endpoints.add("fake.test.endpoint.two");
configStore.setEndpoints(endpoints);
@@ -419,7 +421,7 @@ public void getAvailableClientsWithAutoFailoverTest() {
// Mock auto-failover endpoints
List autoFailoverEndpoints = new ArrayList<>();
- String failoverEndpoint = "https://failover.test.config.io";
+ String failoverEndpoint = "fake.test.failover.endpoint";
autoFailoverEndpoints.add(failoverEndpoint);
when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints);
@@ -446,7 +448,7 @@ public void backoffAutoFailoverClientTest() {
// Set up auto-failover scenario
List autoFailoverEndpoints = new ArrayList<>();
- String failoverEndpoint = "https://failover.test.config.io";
+ String failoverEndpoint = "fake.test.failover.endpoint";
autoFailoverEndpoints.add(failoverEndpoint);
when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints);
@@ -543,7 +545,7 @@ public void getAvailableClientsWithLoadBalancingMixedBackoffTest() {
// Mock auto-failover endpoints (but they should also be backed off)
List autoFailoverEndpoints = new ArrayList<>();
- String failoverEndpoint = "https://failover.test.config.io";
+ String failoverEndpoint = "fake.test.failover.endpoint";
autoFailoverEndpoints.add(failoverEndpoint);
when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints);
@@ -579,5 +581,119 @@ public void getAvailableClientsSingleClientTest() {
assertEquals(AppConfigurationStoreHealth.UP, manager.getHealth());
}
+ /**
+ * Tests getMillisUntilNextClientAvailable returns 0 when a client is available immediately.
+ */
+ @Test
+ public void getMillisUntilNextClientAvailableReturnsZeroWhenClientAvailableTest() {
+ ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock);
+
+ List clients = new ArrayList<>();
+ clients.add(replicaClient1);
+
+ // Client is not in backoff (available now)
+ when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().minusSeconds(60));
+ when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients);
+
+ // Initialize clients by calling getAvailableClients
+ manager.getAvailableClients();
+
+ long waitTime = manager.getMillisUntilNextClientAvailable();
+ assertEquals(0, waitTime);
+ }
+
+ /**
+ * Tests getMillisUntilNextClientAvailable returns wait time when all clients are in backoff.
+ */
+ @Test
+ public void getMillisUntilNextClientAvailableReturnsWaitTimeWhenAllBackedOffTest() {
+ ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock);
+
+ List clients = new ArrayList<>();
+ clients.add(replicaClient1);
+ clients.add(replicaClient2);
+
+ // Both clients are in backoff
+ Instant backoffEnd1 = Instant.now().plusSeconds(10);
+ Instant backoffEnd2 = Instant.now().plusSeconds(5); // This one expires sooner
+ when(replicaClient1.getBackoffEndTime()).thenReturn(backoffEnd1);
+ when(replicaClient2.getBackoffEndTime()).thenReturn(backoffEnd2);
+ when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients);
+
+ // Initialize clients by calling getAvailableClients
+ manager.getAvailableClients();
+
+ long waitTime = manager.getMillisUntilNextClientAvailable();
+
+ // Should return approximately 5 seconds (the earlier backoff end time)
+ assertTrue(waitTime > 0);
+ assertTrue(waitTime <= 5000);
+ }
+
+ /**
+ * Tests getMillisUntilNextClientAvailable considers auto-failover clients.
+ */
+ @Test
+ public void getMillisUntilNextClientAvailableWithAutoFailoverClientTest() {
+ ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock);
+
+ List clients = new ArrayList<>();
+ clients.add(replicaClient1);
+
+ // Regular client is in backoff for longer
+ when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(30));
+ when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients);
+
+ // Setup auto-failover client with shorter backoff
+ List autoFailoverEndpoints = new ArrayList<>();
+ String failoverEndpoint = "fake.test.failover.endpoint";
+ autoFailoverEndpoints.add(failoverEndpoint);
+ when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints);
+
+ // Auto-failover client expires sooner
+ when(autoFailoverClient.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(5));
+ when(clientBuilderMock.buildClient(Mockito.eq(failoverEndpoint), Mockito.eq(configStore))).thenReturn(autoFailoverClient);
+
+ // Initialize clients (will also add auto-failover client)
+ manager.getAvailableClients();
+
+ long waitTime = manager.getMillisUntilNextClientAvailable();
+
+ // Should return approximately 5 seconds (auto-failover client expires first)
+ assertTrue(waitTime > 0);
+ assertTrue(waitTime <= 5000);
+ }
+
+ /**
+ * Tests getMillisUntilNextClientAvailable returns 0 when auto-failover client is available.
+ */
+ @Test
+ public void getMillisUntilNextClientAvailableAutoFailoverAvailableTest() {
+ ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock);
+
+ List clients = new ArrayList<>();
+ clients.add(replicaClient1);
+
+ // Regular client is in backoff
+ when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(30));
+ when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients);
+
+ // Setup auto-failover client that is available
+ List autoFailoverEndpoints = new ArrayList<>();
+ String failoverEndpoint = "fake.test.failover.endpoint";
+ autoFailoverEndpoints.add(failoverEndpoint);
+ when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints);
+
+ // Auto-failover client is available (not in backoff)
+ when(autoFailoverClient.getBackoffEndTime()).thenReturn(Instant.now().minusSeconds(60));
+ when(clientBuilderMock.buildClient(Mockito.eq(failoverEndpoint), Mockito.eq(configStore))).thenReturn(autoFailoverClient);
+
+ // Initialize clients (will also add auto-failover client)
+ manager.getAvailableClients();
+
+ long waitTime = manager.getMillisUntilNextClientAvailable();
+ assertEquals(0, waitTime);
+ }
+
}
diff --git a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md
index 4208aff37153..247279e82870 100644
--- a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md
+++ b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md
@@ -54,15 +54,16 @@ spring.config.import | The Spring property that triggers the loading of Azure Ap
Name | Description | Required | Default
---|---|---|---
-spring.cloud.azure.appconfiguration.stores | List of configuration stores from which to load configuration properties | Yes | true
-spring.cloud.azure.appconfiguration.enabled | Whether enable spring-cloud-azure-appconfiguration-config or not | No | true
-spring.cloud.azure.appconfiguration.refresh-interval | Amount of time, of type Duration, configurations are stored before a check can occur. | No | null
+spring.cloud.azure.appconfiguration.stores | List of configuration stores from which to load configuration properties | Yes | Empty List
+spring.cloud.azure.appconfiguration.enabled | Whether to enable spring-cloud-azure-appconfiguration-config or not | No | true
+spring.cloud.azure.appconfiguration.refresh-interval | Amount of time, of type Duration, configurations are stored before a check can occur. | No | null
+spring.cloud.azure.appconfiguration.startup-timeout | Maximum time to retry loading configuration during application startup when transient failures occur. | No | 100s
`spring.cloud.azure.appconfiguration.stores` is a list of stores, where each store follows the following format:
Name | Description | Required | Default
---|---|---|---
-spring.cloud.azure.appconfiguration.stores[0].enabled | Whether the store will be loaded. | No | true
+spring.cloud.azure.appconfiguration.stores[0].enabled | Whether the store will be loaded. Requires either `spring.config.import= optional:azureAppConfiguration` or another config store to be loaded. | No | true
spring.cloud.azure.appconfiguration.stores[0].fail-fast | Whether to throw a `RuntimeException` or not when failing to read from App Configuration during application start-up. If an exception does occur during startup when set to false the store is skipped. | No | true
spring.cloud.azure.appconfiguration.stores[0].selects[0].key-filter | The key pattern used to indicate which configuration(s) will be loaded. | No | /application/*
spring.cloud.azure.appconfiguration.stores[0].selects[0].label-filter | The label used to indicate which configuration(s) will be loaded. | No | `${spring.profiles.active}` or if null `\0`
From ee8f3f62d05d991f3bd46ef9024b112f57899887 Mon Sep 17 00:00:00 2001
From: Matthew Metcalf
Date: Wed, 1 Apr 2026 00:42:28 +0800
Subject: [PATCH 11/22] App Config Spring - Refresh Refactor (#47877)
* Refactor Refresh
* review fixes
* Apply suggestions from code review
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update StateHolderTest.java
* Update AppConfigurationRefreshUtil.java
* Update AppConfigurationRefreshUtil.java
* Update sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update AzureAppConfigBootstrapRegistrar.java
* Update AppConfigurationRefreshUtilTest.java
* Update ConnectionManager.java
* Update AppConfigurationRefreshUtilTest.java
* fixing after merge
* Update AppConfigurationRefreshUtilTest.java
* fixing tests
* fixing merge issue
* Update RecurrenceEvaluator.java
* Update RecurrenceEvaluator.java
* better fix
* new fix
* Update AppConfigurationWatchAutoConfiguration.java
* Update AppConfigurationPullRefresh.java
* Update AppConfigurationPullRefresh.java
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
(cherry picked from commit dcd90d0e0591eabcb1132488314e5a7308b0100c)
---
...ppConfigurationWatchAutoConfiguration.java | 18 +-
...ationApplicationSettingPropertySource.java | 2 +-
.../AppConfigurationPullRefresh.java | 20 +-
.../AppConfigurationRefreshUtil.java | 67 +-
.../AppConfigurationReplicaClient.java | 2 +-
... => AzureAppConfigBootstrapRegistrar.java} | 12 +
.../implementation/ConnectionManager.java | 10 +-
.../config/implementation/StateHolder.java | 104 +--
.../AppConfigurationProperties.java | 15 +-
.../properties/ConfigStore.java | 18 +-
.../AppConfigurationPullRefreshTest.java | 26 +-
.../AppConfigurationRefreshUtilTest.java | 598 +++++++++---------
.../implementation/StateHolderTest.java | 198 +++---
13 files changed, 538 insertions(+), 552 deletions(-)
rename sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/{AzureAppConfigBoostrapRegistrar.java => AzureAppConfigBootstrapRegistrar.java} (91%)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java
index 9b0d4be6c0a0..f977dd619675 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java
@@ -15,6 +15,7 @@
import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationPullRefresh;
import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationRefreshUtil;
import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationReplicaClientFactory;
+import com.azure.spring.cloud.appconfiguration.config.implementation.StateHolder;
import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp;
import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationProperties;
@@ -37,11 +38,20 @@ public AppConfigurationWatchAutoConfiguration() {
@Bean
@ConditionalOnMissingBean
AppConfigurationRefresh appConfigurationRefresh(AppConfigurationProperties properties, BootstrapContext context) {
- AppConfigurationReplicaClientFactory clientFactory = context
- .get(AppConfigurationReplicaClientFactory.class);
- ReplicaLookUp replicaLookUp = context.get(ReplicaLookUp.class);
+ AppConfigurationReplicaClientFactory clientFactory = context.getOrElse(AppConfigurationReplicaClientFactory.class, null);
+ ReplicaLookUp replicaLookUp = context.getOrElse(ReplicaLookUp.class, null);
+
+ StateHolder stateHolder = context.getOrElse(StateHolder.class, null);
+
+ if (clientFactory == null || replicaLookUp == null) {
+ return null;
+ }
+
+ if (stateHolder == null) {
+ stateHolder = new StateHolder();
+ }
return new AppConfigurationPullRefresh(clientFactory, properties.getRefreshInterval(), replicaLookUp,
- new AppConfigurationRefreshUtil());
+ stateHolder, new AppConfigurationRefreshUtil(stateHolder));
}
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java
index dd0c70cf3c8e..4fcc931cb93e 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationApplicationSettingPropertySource.java
@@ -65,6 +65,7 @@ class AppConfigurationApplicationSettingPropertySource extends AppConfigurationP
* @param keyPrefixTrimValues prefixs to trim from key values
* @throws InvalidConfigurationPropertyValueException thrown if fails to parse Json content type
*/
+ @Override
public void initProperties(List keyPrefixTrimValues, Context context) throws InvalidConfigurationPropertyValueException {
List labels = Arrays.asList(labelFilters);
@@ -136,7 +137,6 @@ private void handleKeyVaultReference(String key, SecretReferenceConfigurationSet
void handleFeatureFlag(String key, FeatureFlagConfigurationSetting setting, List trimStrings)
throws InvalidConfigurationPropertyValueException {
// Feature Flags aren't loaded as configuration, but are loaded as feature flags when loading a snapshot.
- return;
}
private void handleJson(ConfigurationSetting setting, List keyPrefixTrimValues)
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java
index cb5d5735d39b..2b37e4ccfd5b 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java
@@ -36,7 +36,6 @@ public class AppConfigurationPullRefresh implements AppConfigurationRefresh {
* Publisher for Spring refresh events.
*/
private ApplicationEventPublisher publisher;
- private final Long defaultMinBackoff = (long) 30;
/**
* Default minimum backoff duration in seconds when refresh operations fail.
@@ -63,19 +62,30 @@ public class AppConfigurationPullRefresh implements AppConfigurationRefresh {
*/
private final AppConfigurationRefreshUtil refreshUtils;
+ /**
+ * Holds configuration state between refreshes.
+ */
+ private final StateHolder stateHolder;
+
/**
* Creates a new AppConfigurationPullRefresh component.
*
* @param clientFactory factory for creating App Configuration clients to connect to stores
* @param refreshInterval time duration between refresh interval checks
* @param replicaLookUp component for handling replica lookup and failover
+ * @param stateHolder holds configuration state between refreshes
* @param refreshUtils utility component for refresh operations
*/
public AppConfigurationPullRefresh(AppConfigurationReplicaClientFactory clientFactory, Duration refreshInterval,
- ReplicaLookUp replicaLookUp, AppConfigurationRefreshUtil refreshUtils) {
+ ReplicaLookUp replicaLookUp, StateHolder stateHolder, AppConfigurationRefreshUtil refreshUtils) {
this.refreshInterval = refreshInterval;
this.clientFactory = clientFactory;
this.replicaLookUp = replicaLookUp;
+ if (stateHolder == null) {
+ // StateHolder is null if all stores are disabled.
+ stateHolder = new StateHolder();
+ }
+ this.stateHolder = stateHolder;
this.refreshUtils = refreshUtils;
}
@@ -96,6 +106,7 @@ public void setApplicationEventPublisher(ApplicationEventPublisher applicationEv
* @return a Mono containing a boolean indicating if a RefreshEvent was published. Returns {@code false} if
* refreshConfigurations is currently being executed elsewhere.
*/
+ @Override
public Mono refreshConfigurations() {
return Mono.just(refreshStores());
}
@@ -107,6 +118,7 @@ public Mono refreshConfigurations() {
* @param endpoint the Config Store endpoint to expire refresh interval on
* @param syncToken the syncToken to verify the latest changes are available on pull
*/
+ @Override
public void expireRefreshInterval(String endpoint, String syncToken) {
LOGGER.debug("Expiring refresh interval for " + endpoint);
@@ -114,7 +126,7 @@ public void expireRefreshInterval(String endpoint, String syncToken) {
clientFactory.updateSyncToken(originEndpoint, endpoint, syncToken);
- StateHolder.getCurrentState().expireState(originEndpoint);
+ stateHolder.expireState(originEndpoint);
}
/**
@@ -135,7 +147,7 @@ private boolean refreshStores() {
} catch (Exception e) {
LOGGER.warn("Error occurred during configuration refresh, will retry at next interval", e);
// The next refresh will happen sooner if refresh interval is expired.
- StateHolder.getCurrentState().updateNextRefreshTime(refreshInterval, DEFAULT_MIN_BACKOFF_SECONDS);
+ stateHolder.updateNextRefreshTime(refreshInterval, DEFAULT_MIN_BACKOFF_SECONDS);
throw e;
} finally {
running.set(false);
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
index e17572efa6a9..abec63fa1ba8 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
@@ -32,6 +32,21 @@ public class AppConfigurationRefreshUtil {
private static final String FEATURE_FLAG_PREFIX = ".appconfig.featureflag/*";
+ private final StateHolder stateHolder;
+
+ /**
+ * Creates a new AppConfigurationRefreshUtil with the specified state holder.
+ *
+ * @param stateHolder the state holder for managing configuration and feature flag states
+ */
+ public AppConfigurationRefreshUtil(StateHolder stateHolder) {
+ if (stateHolder == null) {
+ // This is a fallback if all stores are disabled.
+ stateHolder = new StateHolder();
+ }
+ this.stateHolder = stateHolder;
+ }
+
/**
* Functional interface for refresh operations that can throw AppConfigurationStatusException.
*/
@@ -56,8 +71,8 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
RefreshEventData eventData = new RefreshEventData();
try {
- if (refreshInterval != null && StateHolder.getNextForcedRefresh() != null
- && Instant.now().isAfter(StateHolder.getNextForcedRefresh())) {
+ if (refreshInterval != null && stateHolder.getNextForcedRefresh() != null
+ && Instant.now().isAfter(stateHolder.getNextForcedRefresh())) {
String eventDataInfo = "Minimum refresh period reached. Refreshing configurations.";
LOGGER.info(eventDataInfo);
@@ -84,12 +99,20 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
clientFactory.findActiveClients(originEndpoint);
- if (monitor.isEnabled() && StateHolder.getLoadState(originEndpoint)) {
+ if (monitor.isEnabled() && stateHolder.getLoadState(originEndpoint)) {
RefreshEventData result = executeRefreshWithRetry(
clientFactory,
originEndpoint,
- (client, data, ctx) -> refreshWithTime(client, StateHolder.getState(originEndpoint),
- monitor.getRefreshInterval(), data, replicaLookUp, ctx),
+ (client, data, ctx) -> {
+ if (stateHolder.getState(originEndpoint) == null) {
+ LOGGER.debug(
+ "Skipping configuration refresh check for {} because monitoring state is not initialized.",
+ originEndpoint);
+ return;
+ }
+ refreshWithTime(client, stateHolder.getState(originEndpoint),
+ monitor.getRefreshInterval(), data, replicaLookUp, ctx);
+ },
eventData,
context,
"configuration refresh check",
@@ -103,12 +126,12 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
FeatureFlagStore featureStore = connection.getFeatureFlagStore();
- if (featureStore.getEnabled() && StateHolder.getStateFeatureFlag(originEndpoint) != null) {
+ if (featureStore.getEnabled() && stateHolder.getStateFeatureFlag(originEndpoint) != null) {
RefreshEventData result = executeRefreshWithRetry(
clientFactory,
originEndpoint,
(client, data, ctx) -> refreshWithTimeFeatureFlags(client,
- StateHolder.getStateFeatureFlag(originEndpoint),
+ stateHolder.getStateFeatureFlag(originEndpoint),
monitor.getFeatureFlagRefreshInterval(), data, replicaLookUp, ctx),
eventData,
context,
@@ -124,7 +147,7 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF
}
} catch (Exception e) {
// The next refresh will happen sooner if refresh interval is expired.
- StateHolder.getCurrentState().updateNextRefreshTime(refreshInterval, defaultMinBackoff);
+ stateHolder.updateNextRefreshTime(refreshInterval, defaultMinBackoff);
throw e;
}
return eventData;
@@ -179,12 +202,20 @@ private RefreshEventData executeRefreshWithRetry(
* @param client the client for checking refresh status
* @param originEndpoint the original config store endpoint
* @param context the operation context
+ * @param stateHolder the state holder instance
* @return true if a refresh should be triggered, false otherwise
*/
- static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String originEndpoint, Context context) {
+ static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String originEndpoint, Context context,
+ StateHolder stateHolder) {
RefreshEventData eventData = new RefreshEventData();
- if (StateHolder.getLoadState(originEndpoint)) {
- refreshWithoutTime(client, StateHolder.getState(originEndpoint).getWatchKeys(), eventData, context);
+ if (stateHolder.getLoadState(originEndpoint)) {
+ State state = stateHolder.getState(originEndpoint);
+ if (state != null) {
+ refreshWithoutTime(client, state.getWatchKeys(), eventData, context);
+ } else {
+ LOGGER.debug("Skipping configuration refresh check for {} as no watched state is available",
+ originEndpoint);
+ }
}
return eventData.getDoRefresh();
}
@@ -198,13 +229,13 @@ static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String or
* @param context the operation context
* @return true if a refresh should be triggered, false otherwise
*/
- static boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled,
+ boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled,
AppConfigurationReplicaClient client, Context context) {
RefreshEventData eventData = new RefreshEventData();
String endpoint = client.getEndpoint();
- if (featureStoreEnabled && StateHolder.getStateFeatureFlag(endpoint) != null) {
- refreshWithoutTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(endpoint), eventData, context);
+ if (featureStoreEnabled && stateHolder.getStateFeatureFlag(endpoint) != null) {
+ refreshWithoutTimeFeatureFlags(client, stateHolder.getStateFeatureFlag(endpoint), eventData, context);
} else {
LOGGER.debug("Skipping feature flag refresh check for {}", endpoint);
}
@@ -223,7 +254,7 @@ static boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled,
* @param context the operation context
* @throws AppConfigurationStatusException if there's an error during the refresh check
*/
- private static void refreshWithTime(AppConfigurationReplicaClient client, State state, Duration refreshInterval,
+ private void refreshWithTime(AppConfigurationReplicaClient client, State state, Duration refreshInterval,
RefreshEventData eventData, ReplicaLookUp replicaLookUp, Context context)
throws AppConfigurationStatusException {
if (Instant.now().isAfter(state.getNextRefreshCheck())) {
@@ -238,7 +269,7 @@ private static void refreshWithTime(AppConfigurationReplicaClient client, State
refreshWithoutTime(client, state.getWatchKeys(), eventData, context);
}
- StateHolder.getCurrentState().updateStateRefresh(state, refreshInterval);
+ stateHolder.updateStateRefresh(state, refreshInterval);
}
}
@@ -308,7 +339,7 @@ private static void refreshWithoutTimeWatchedConfigurationSettings(AppConfigurat
* @param context the operation context
* @throws AppConfigurationStatusException if there's an error during the refresh check
*/
- private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState state,
+ private void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState state,
Duration refreshInterval, RefreshEventData eventData, ReplicaLookUp replicaLookUp, Context context)
throws AppConfigurationStatusException {
Instant date = Instant.now();
@@ -327,7 +358,7 @@ private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient cl
}
- StateHolder.getCurrentState().updateFeatureFlagStateRefresh(state, refreshInterval);
+ stateHolder.updateFeatureFlagStateRefresh(state, refreshInterval);
}
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java
index 049e0ed65546..edb761e0e737 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java
@@ -249,7 +249,7 @@ boolean checkWatchKeys(SettingSelector settingSelector, Context context) {
List> results = client
.listConfigurationSettings(settingSelector, context)
.streamByPage().filter(pagedResponse -> pagedResponse.getStatusCode() != HTTP_NOT_MODIFIED).toList();
- return results.size() > 0;
+ return !results.isEmpty();
}
/**
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBootstrapRegistrar.java
similarity index 91%
rename from sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java
rename to sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBootstrapRegistrar.java
index fb0a090f0910..f43186e32714 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBootstrapRegistrar.java
@@ -6,6 +6,7 @@
import org.springframework.boot.context.config.ConfigDataLocationResolverContext;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
+import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.util.StringUtils;
import com.azure.data.appconfiguration.ConfigurationClientBuilder;
@@ -51,6 +52,17 @@ static void register(ConfigDataLocationResolverContext context, Binder binder,
InstanceSupplier.from(() -> keyVaultClientFactory));
context.getBootstrapContext().registerIfAbsent(AppConfigurationReplicaClientFactory.class,
InstanceSupplier.from(() -> buildClientFactory(replicaClientsBuilder, properties, replicaLookup)));
+
+ // Register StateHolder and promote it to ApplicationContext on close
+ context.getBootstrapContext().registerIfAbsent(StateHolder.class,
+ InstanceSupplier.from(StateHolder::new));
+ context.getBootstrapContext().addCloseListener(event -> {
+ StateHolder stateHolder = event.getBootstrapContext().get(StateHolder.class);
+ ConfigurableApplicationContext applicationContext = event.getApplicationContext();
+ if (!applicationContext.getBeanFactory().containsBean("appConfigurationStateHolder")) {
+ applicationContext.getBeanFactory().registerSingleton("appConfigurationStateHolder", stateHolder);
+ }
+ });
}
private static AppConfigurationKeyVaultClientFactory appConfigurationKeyVaultClientFactory(
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
index de1396223925..46a9e0f4920e 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
@@ -169,7 +169,7 @@ public List getAvailableClients() {
if (clients.get(0).getBackoffEndTime().isBefore(Instant.now())) {
availableClients.add(clients.get(0));
}
- } else if (clients.size() > 0 && !configStore.isLoadBalancingEnabled()) {
+ } else if (!clients.isEmpty() && !configStore.isLoadBalancingEnabled()) {
for (AppConfigurationReplicaClient replicaClient : clients) {
if (replicaClient.getBackoffEndTime().isBefore(Instant.now())) {
LOGGER.debug("Using Client: " + replicaClient.getEndpoint());
@@ -184,10 +184,10 @@ public List getAvailableClients() {
}
}
- if (availableClients.size() == 0 || configStore.isLoadBalancingEnabled()) {
+ if (availableClients.isEmpty() || configStore.isLoadBalancingEnabled()) {
List autoFailoverEndpoints = replicaLookUp.getAutoFailoverEndpoints(configStore.getEndpoint());
- if (autoFailoverEndpoints.size() > 0) {
+ if (!autoFailoverEndpoints.isEmpty()) {
for (String failoverEndpoint : autoFailoverEndpoints) {
AppConfigurationReplicaClient client = autoFailoverClients.get(failoverEndpoint);
if (client == null) {
@@ -201,9 +201,9 @@ public List getAvailableClients() {
}
}
}
- if (clients.size() > 0 && availableClients.size() == 0) {
+ if (!clients.isEmpty() && availableClients.isEmpty()) {
this.health = AppConfigurationStoreHealth.DOWN;
- } else if (clients.size() > 0) {
+ } else if (!clients.isEmpty()) {
this.health = AppConfigurationStoreHealth.UP;
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
index e9ff389e6570..6ec13ae92cd9 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
@@ -15,21 +15,23 @@
import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState;
/**
- * Thread-safe singleton holder for managing refresh state of Azure App Configuration stores.
+ * Thread-safe holder for managing refresh state of Azure App Configuration stores.
*
*
* Maintains state for configuration settings, feature flags, and refresh intervals across multiple configuration
* stores. Implements exponential backoff for failed refresh attempts and coordinates the timing of refresh operations.
*
+ *
+ *
+ * This class is registered in the BootstrapContext during initial configuration loading and promoted to the main
+ * ApplicationContext for use during refresh operations.
+ *
*/
-final class StateHolder {
+public class StateHolder {
/** Maximum jitter in seconds to add when expiring state to prevent thundering herd. */
private static final int MAX_JITTER = 15;
- /** The current singleton instance of StateHolder. */
- private static StateHolder currentState;
-
/** Map of configuration store endpoints to their refresh state. */
private final Map state = new ConcurrentHashMap<>();
@@ -45,25 +47,10 @@ final class StateHolder {
/** The next time a forced refresh should occur across all stores. */
private Instant nextForcedRefresh;
- StateHolder() {
- }
-
- /**
- * Gets the current singleton instance of StateHolder.
- * @return the current StateHolder instance, or null if not yet initialized
- */
- static StateHolder getCurrentState() {
- return currentState;
- }
-
/**
- * Updates the singleton instance to a new StateHolder.
- * @param newState the new StateHolder instance to set as current
- * @return the updated StateHolder instance
+ * Creates a new StateHolder instance.
*/
- static StateHolder updateState(StateHolder newState) {
- currentState = newState;
- return currentState;
+ public StateHolder() {
}
/**
@@ -71,41 +58,34 @@ static StateHolder updateState(StateHolder newState) {
* @param originEndpoint the endpoint for the origin config store
* @return the State for the specified store, or null if not found
*/
- static State getState(String originEndpoint) {
- return currentState.getFullState().get(originEndpoint);
+ public State getState(String originEndpoint) {
+ return state.get(originEndpoint);
}
/**
- * Gets the full map of configuration store states.
- * @return map of endpoint to State
- */
- private Map getFullState() {
- return state;
- }
-
- /**
- * Gets the full map of feature flag states.
- * @return map of endpoint to FeatureFlagState
+ * Retrieves the feature flag refresh state for a specific configuration store.
+ * @param originEndpoint the endpoint for the origin config store
+ * @return the FeatureFlagState for the specified store, or null if not found
*/
- private Map getFullFeatureFlagState() {
- return featureFlagState;
+ public FeatureFlagState getStateFeatureFlag(String originEndpoint) {
+ return featureFlagState.get(originEndpoint);
}
/**
- * Gets the full map of load states.
- * @return map of endpoint to load status
+ * Checks if a configuration store has been successfully loaded.
+ * @param originEndpoint the endpoint of the store to check
+ * @return true if the store has been loaded, false otherwise
*/
- private Map getFullLoadState() {
- return loadState;
+ public boolean getLoadState(String originEndpoint) {
+ return loadState.getOrDefault(originEndpoint, false);
}
/**
- * Retrieves the feature flag refresh state for a specific configuration store.
- * @param originEndpoint the endpoint for the origin config store
- * @return the FeatureFlagState for the specified store, or null if not found
+ * Gets the next time a forced refresh should occur across all stores.
+ * @return the Instant of the next forced refresh, or null if not set
*/
- static FeatureFlagState getStateFeatureFlag(String originEndpoint) {
- return currentState.getFullFeatureFlagState().get(originEndpoint);
+ public Instant getNextForcedRefresh() {
+ return nextForcedRefresh;
}
/**
@@ -114,7 +94,7 @@ static FeatureFlagState getStateFeatureFlag(String originEndpoint) {
* @param watchKeys list of configuration watch keys that can trigger a refresh event
* @param duration refresh duration
*/
- void setState(String originEndpoint, List watchKeys, Duration duration) {
+ public void setState(String originEndpoint, List watchKeys, Duration duration) {
state.put(originEndpoint, new State(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint));
}
@@ -125,7 +105,7 @@ void setState(String originEndpoint, List watchKeys, Durat
* @param collectionWatchKeys list of collection monitoring configurations that can trigger a refresh event
* @param duration refresh duration
*/
- void setState(String originEndpoint, List watchKeys,
+ public void setState(String originEndpoint, List watchKeys,
List collectionWatchKeys, Duration duration) {
state.put(originEndpoint,
new State(watchKeys, collectionWatchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint));
@@ -137,7 +117,7 @@ void setState(String originEndpoint, List watchKeys,
* @param watchKeys list of feature flag watch keys that can trigger a refresh event
* @param duration refresh duration
*/
- void setStateFeatureFlag(String originEndpoint, List watchKeys,
+ public void setStateFeatureFlag(String originEndpoint, List watchKeys,
Duration duration) {
featureFlagState.put(originEndpoint,
new FeatureFlagState(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint));
@@ -158,7 +138,7 @@ void updateStateRefresh(State state, Duration duration) {
* @param state the current FeatureFlagState to update
* @param duration the duration to add to the current time for the next refresh
*/
- void updateFeatureFlagStateRefresh(FeatureFlagState state, Duration duration) {
+ public void updateFeatureFlagStateRefresh(FeatureFlagState state, Duration duration) {
this.featureFlagState.put(state.getOriginEndpoint(),
new FeatureFlagState(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds()))));
}
@@ -168,8 +148,11 @@ void updateFeatureFlagStateRefresh(FeatureFlagState state, Duration duration) {
* prevent thundering herd when multiple stores refresh simultaneously.
* @param originEndpoint the endpoint of the store to expire
*/
- void expireState(String originEndpoint) {
+ public void expireState(String originEndpoint) {
State oldState = state.get(originEndpoint);
+ if (oldState == null) {
+ return;
+ }
long wait = (long) (new SecureRandom().nextDouble() * MAX_JITTER);
long timeLeft = (int) ((oldState.getNextRefreshCheck().toEpochMilli() - (Instant.now().toEpochMilli())) / 1000);
@@ -178,31 +161,14 @@ void expireState(String originEndpoint) {
}
}
- /**
- * Checks if a configuration store has been successfully loaded.
- * @param originEndpoint the endpoint of the store to check
- * @return true if the store has been loaded, false otherwise
- */
- static boolean getLoadState(String originEndpoint) {
- return currentState.getFullLoadState().getOrDefault(originEndpoint, false);
- }
-
/**
* @param originEndpoint the configuration store connected to.
* @param loaded true if the configuration store was loaded.
*/
- void setLoadState(String originEndpoint, Boolean loaded) {
+ public void setLoadState(String originEndpoint, Boolean loaded) {
loadState.put(originEndpoint, loaded);
}
- /**
- * Gets the next time a forced refresh should occur across all stores.
- * @return the Instant of the next forced refresh, or null if not set
- */
- public static Instant getNextForcedRefresh() {
- return currentState.nextForcedRefresh;
- }
-
/**
* Sets the next forced refresh time. Called after a successful load or refresh.
* @param refreshPeriod the duration from now until the next forced refresh; if null, no refresh is scheduled
@@ -220,7 +186,7 @@ public void setNextForcedRefresh(Duration refreshPeriod) {
* @param refreshInterval period between refresh checks
* @param defaultMinBackoff minimum backoff duration between checks in seconds
*/
- void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) {
+ public void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) {
if (refreshInterval != null) {
Instant newForcedRefresh = getNextRefreshCheck(nextForcedRefresh,
clientRefreshAttempts, refreshInterval.getSeconds(), defaultMinBackoff);
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
index 5745e5b5b37b..adfe72d0627b 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java
@@ -115,19 +115,24 @@ public void setStartupTimeout(Duration startupTimeout) {
public void validateAndInit() {
Assert.notEmpty(this.stores, "At least one config store has to be configured.");
- this.stores.forEach(store -> {
+ for (ConfigStore store : this.stores) {
+ if (!store.isEnabled()) {
+ continue;
+ }
Assert.isTrue(
StringUtils.hasText(store.getEndpoint()) || StringUtils.hasText(store.getConnectionString())
- || store.getEndpoints().size() > 0 || store.getConnectionStrings().size() > 0,
+ || !store.getEndpoints().isEmpty() || !store.getConnectionStrings().isEmpty(),
"Either configuration store name or connection string should be configured.");
store.validateAndInit();
- });
+ }
Map existingEndpoints = new HashMap<>();
for (ConfigStore store : this.stores) {
-
- if (store.getEndpoints().size() > 0) {
+ if (!store.isEnabled()) {
+ continue;
+ }
+ if (!store.getEndpoints().isEmpty()) {
for (String endpoint : store.getEndpoints()) {
if (existingEndpoints.containsKey(endpoint)) {
throw new IllegalArgumentException("Duplicate store name exists.");
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java
index 83409f1d16f8..ffa0450762ad 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java
@@ -301,29 +301,29 @@ public void validateAndInit() {
}
if (StringUtils.hasText(connectionString)) {
- String endpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connectionString));
+ String validationEndpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connectionString));
try {
// new URI is used to validate the endpoint as a valid URI
- new URI(endpoint);
- this.endpoint = endpoint;
- } catch (URISyntaxException e) {
+ new URI(validationEndpoint).toURL();
+ this.endpoint = validationEndpoint;
+ } catch (URISyntaxException | MalformedURLException | IllegalArgumentException e) {
throw new IllegalStateException("Endpoint in connection string is not a valid URI.", e);
}
- } else if (connectionStrings.size() > 0) {
+ } else if (!connectionStrings.isEmpty()) {
for (String connection : connectionStrings) {
- String endpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connection));
+ String validationEndpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connection));
try {
// new URI is used to validate the endpoint as a valid URI
- new URI(endpoint).toURL();
+ new URI(validationEndpoint).toURL();
if (!StringUtils.hasText(this.endpoint)) {
- this.endpoint = endpoint;
+ this.endpoint = validationEndpoint;
}
} catch (MalformedURLException | URISyntaxException | IllegalArgumentException e) {
throw new IllegalStateException("Endpoint in connection string is not a valid URI.", e);
}
}
- } else if (endpoints.size() > 0) {
+ } else if (!endpoints.isEmpty()) {
endpoint = endpoints.get(0);
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java
index 72bb068ecb71..5e3ba0d4cf61 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java
@@ -22,14 +22,11 @@
import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationRefreshUtil.RefreshEventData;
import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp;
-import net.jcip.annotations.NotThreadSafe;
-
-@NotThreadSafe
public class AppConfigurationPullRefreshTest {
@Mock
private ApplicationEventPublisher publisher;
-
+
@Mock
private ReplicaLookUp replicaLookUpMock;
@@ -40,10 +37,13 @@ public class AppConfigurationPullRefreshTest {
@Mock
private AppConfigurationReplicaClientFactory clientFactoryMock;
-
+
@Mock
private AppConfigurationRefreshUtil refreshUtilMock;
-
+
+ @Mock
+ private StateHolder stateHolderMock;
+
private MockitoSession session;
@BeforeEach
@@ -60,24 +60,26 @@ public void cleanup() throws Exception {
@Test
public void refreshNoChange() throws InterruptedException, ExecutionException {
- when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(eventDataMock);
+ when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(eventDataMock);
AppConfigurationPullRefresh refresh = new AppConfigurationPullRefresh(clientFactoryMock, refreshInterval,
- replicaLookUpMock, refreshUtilMock);
+ replicaLookUpMock, stateHolderMock, refreshUtilMock);
assertFalse(refresh.refreshConfigurations().block());
-
+
}
@Test
public void refreshUpdate() throws InterruptedException, ExecutionException {
when(eventDataMock.getMessage()).thenReturn("Updated");
when(eventDataMock.getDoRefresh()).thenReturn(true);
- when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any())).thenReturn(eventDataMock);
+ when(refreshUtilMock.refreshStoresCheck(Mockito.any(), Mockito.any(), Mockito.any(), Mockito.any()))
+ .thenReturn(eventDataMock);
AppConfigurationPullRefresh refresh = new AppConfigurationPullRefresh(clientFactoryMock, refreshInterval,
- replicaLookUpMock, refreshUtilMock);
+ replicaLookUpMock, stateHolderMock, refreshUtilMock);
refresh.setApplicationEventPublisher(publisher);
assertTrue(refresh.refreshConfigurations().block());
-
+
}
}
diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
index ebe6ea4d1fa1..02eb4b8dfc93 100644
--- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
+++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
@@ -2,6 +2,16 @@
// Licensed under the MIT License.
package com.azure.spring.cloud.appconfiguration.config.implementation;
+import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL;
+import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_PREFIX;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
@@ -9,19 +19,12 @@
import java.util.Map;
import org.junit.jupiter.api.AfterEach;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
-import org.mockito.MockedStatic;
import org.mockito.Mockito;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
import org.mockito.MockitoAnnotations;
import org.mockito.MockitoSession;
import org.mockito.quality.Strictness;
@@ -30,8 +33,6 @@
import com.azure.data.appconfiguration.models.ConfigurationSetting;
import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting;
import com.azure.data.appconfiguration.models.SettingSelector;
-import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL;
-import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_PREFIX;
import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationRefreshUtil.RefreshEventData;
import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp;
import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings;
@@ -72,6 +73,8 @@ public class AppConfigurationRefreshUtilTest {
private String endpoint;
+ private AppConfigurationRefreshUtil refreshUtil;
+
private final List watchKeysFeatureFlags = generateFeatureFlagWatchKeys();
private final AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring();
@@ -95,6 +98,8 @@ public void setup() {
monitoring.setEnabled(true);
featureStore.setEnabled(true);
+
+ refreshUtil = new AppConfigurationRefreshUtil(currentStateMock);
}
@AfterEach
@@ -107,13 +112,10 @@ public void cleanup() throws Exception {
public void refreshWithoutTimeWatchKeyConfigStoreNotLoaded(TestInfo testInfo) {
endpoint = testInfo.getDisplayName() + ".azconfig.io";
when(clientMock.getEndpoint()).thenReturn(endpoint);
+ when(currentStateMock.getLoadState(endpoint)).thenReturn(false);
- try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
- stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(false);
-
- assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock));
- assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
- }
+ assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock));
+ assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
}
@Test
@@ -127,13 +129,11 @@ public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNotReturned(TestInfo te
// Config Store doesn't return a watch key change.
when(clientMock.getWatchKey(Mockito.eq(KEY_FILTER), Mockito.eq(EMPTY_LABEL), Mockito.any(Context.class)))
.thenReturn(watchKeys.get(0));
- try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
- stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true);
- stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState);
+ when(currentStateMock.getLoadState(endpoint)).thenReturn(true);
+ when(currentStateMock.getState(endpoint)).thenReturn(newState);
- assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock));
- assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
- }
+ assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock));
+ assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
}
@Test
@@ -149,12 +149,10 @@ public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNoChange(TestInfo testI
// Config Store does return a watch key change.
when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class)))
.thenReturn(false);
- try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
- stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState);
+ when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(newState);
- assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock));
- assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
- }
+ assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock));
+ assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
}
@Test
@@ -164,11 +162,9 @@ public void refreshWithoutTimeFeatureFlagDisabled(TestInfo testInfo) {
configStore.getFeatureFlags().setEnabled(false);
- try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
- assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock));
- assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
- stateHolderMock.verify(() -> StateHolder.getLoadState(Mockito.anyString()), times(1));
- }
+ assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock));
+ assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
+ verify(currentStateMock, times(1)).getLoadState(Mockito.anyString());
}
@Test
@@ -178,11 +174,9 @@ public void refreshWithoutTimeFeatureFlagNotLoaded(TestInfo testInfo) {
configStore.getFeatureFlags().setEnabled(true);
- try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
- assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock));
- assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
- stateHolderMock.verify(() -> StateHolder.getLoadState(Mockito.anyString()), times(1));
- }
+ assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock));
+ assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
+ verify(currentStateMock, times(1)).getLoadState(Mockito.anyString());
}
@Test
@@ -198,13 +192,10 @@ public void refreshWithoutTimeFeatureFlagNoChange(TestInfo testInfo) {
// Config Store doesn't return a watch key change.
when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class)))
.thenReturn(false);
- try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
- stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState);
-
- assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock));
- assertFalse(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
- }
+ when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(newState);
+ assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock));
+ assertFalse(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
}
@Test
@@ -220,12 +211,10 @@ public void refreshWithoutTimeFeatureFlagEtagChanged(TestInfo testInfo) {
// Config Store does return a watch key change.
when(clientMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class)))
.thenReturn(true);
- try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
- stateHolderMock.when(() -> StateHolder.getStateFeatureFlag(endpoint)).thenReturn(newState);
+ when(currentStateMock.getStateFeatureFlag(endpoint)).thenReturn(newState);
- assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock));
- assertTrue(AppConfigurationRefreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
- }
+ assertFalse(AppConfigurationRefreshUtil.refreshStoreCheck(clientMock, endpoint, contextMock, currentStateMock));
+ assertTrue(refreshUtil.refreshStoreFeatureFlagCheck(true, clientMock, contextMock));
}
@Test
@@ -236,21 +225,12 @@ public void refreshStoresCheckSettingsTestNotEnabled(TestInfo testInfo) {
when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore);
when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
- State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint);
-
- // Config Store doesn't return a watch key change.
- try (MockedStatic