diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pullrefresh/AppConfigurationEventListener.java b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pullrefresh/AppConfigurationEventListener.java index 8fafe59374a6..5d948a8c462e 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pullrefresh/AppConfigurationEventListener.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config-web/src/main/java/com/azure/spring/cloud/appconfiguration/config/web/implementation/pullrefresh/AppConfigurationEventListener.java @@ -2,10 +2,6 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.web.implementation.pullrefresh; -import static com.azure.spring.cloud.appconfiguration.config.web.implementation.AppConfigurationWebConstants.ACTUATOR; -import static com.azure.spring.cloud.appconfiguration.config.web.implementation.AppConfigurationWebConstants.APPCONFIGURATION_REFRESH; -import static com.azure.spring.cloud.appconfiguration.config.web.implementation.AppConfigurationWebConstants.APPCONFIGURATION_REFRESH_BUS; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationListener; @@ -13,6 +9,9 @@ import org.springframework.web.context.support.ServletRequestHandledEvent; import com.azure.spring.cloud.appconfiguration.config.AppConfigurationRefresh; +import static com.azure.spring.cloud.appconfiguration.config.web.implementation.AppConfigurationWebConstants.ACTUATOR; +import static com.azure.spring.cloud.appconfiguration.config.web.implementation.AppConfigurationWebConstants.APPCONFIGURATION_REFRESH; +import static com.azure.spring.cloud.appconfiguration.config.web.implementation.AppConfigurationWebConstants.APPCONFIGURATION_REFRESH_BUS; /** * Listens for ServletRequestHandledEvents to check if the configurations need to be updated. 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..f04ef81c78d8 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,17 @@ public class AppConfigurationRefreshUtil { private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationRefreshUtil.class); + private static final String FEATURE_FLAG_PATTERN = ".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 +78,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 +129,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. @@ -172,7 +204,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 +226,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 collection monitoring first if configured + List watchedSettings = state.getCollectionWatchKeys(); + if (watchedSettings != null && !watchedSettings.isEmpty()) { + refreshWithoutTimeCollectionMonitoring(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 +266,33 @@ private static void refreshWithoutTime(AppConfigurationReplicaClient client, Lis } } + /** + * Checks configuration collection monitoring for etag changes without time validation. This method immediately + * checks all collection monitoring selectors for changes regardless of refresh intervals. + * + * @param client the App Configuration client to use for checking + * @param collectionWatchKeys the list of collection monitoring configurations 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 refreshWithoutTimeCollectionMonitoring(AppConfigurationReplicaClient client, + List collectionWatchKeys, RefreshEventData eventData, Context context) + throws AppConfigurationStatusException { + for (WatchedConfigurationSettings collectionMonitoring : collectionWatchKeys) { + if (client.checkWatchKeys(collectionMonitoring.getSettingSelector(), context)) { + String eventDataInfo = collectionMonitoring.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 collection monitoring: {}", 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 +312,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_PATTERN); - eventData.setMessage(eventDataInfo); + eventData.setMessage(FEATURE_FLAG_PATTERN); return; } @@ -276,15 +341,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_PATTERN); - eventData.setMessage(eventDataInfo); + eventData.setMessage(FEATURE_FLAG_PATTERN); } } @@ -313,9 +376,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 +388,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; 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..511d9d4532fd 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 collection monitoring. 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 CollectionMonitoring containing the retrieved configuration settings and match conditions + * @throws HttpResponseException if the request fails + */ + WatchedConfigurationSettings collectionMonitoring(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 CollectionMonitoring 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 5979d37b7f53..e7f3c7e350dd 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; @@ -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,20 @@ 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 collection monitoring + if (monitoring.getTriggers().size() == 0) { + // Use collection monitoring for refresh + List collectionMonitoringList = getWatchedConfigurationSettings(currentClient); + storeState.setState(resource.getEndpoint(), Collections.emptyList(), collectionMonitoringList, 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; @@ -259,16 +268,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 CollectionMonitoring * @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); } @@ -276,6 +285,40 @@ private List createFeatureFlags(AppConfigurationReplicaClient clie return featureFlagWatchKeys; } + /** + * Creates a list of collection monitoring 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 CollectionMonitoring for configuration settings + * @throws Exception creating collection monitoring failed + */ + private List getWatchedConfigurationSettings(AppConfigurationReplicaClient client) + throws Exception { + List collectionMonitoringList = new ArrayList<>(); + List selects = resource.getSelects(); + List profiles = resource.getProfiles().getActive(); + + for (AppConfigurationKeyValueSelector selectedKeys : selects) { + // Skip snapshots - they don't support collection monitoring + if (StringUtils.hasText(selectedKeys.getSnapshotName())) { + continue; + } + + // Create collection monitoring for each label + for (String label : selectedKeys.getLabelFilter(profiles)) { + SettingSelector settingSelector = new SettingSelector() + .setKeyFilter(selectedKeys.getKeyFilter() + "*") + .setLabelFilter(label); + + WatchedConfigurationSettings collectionMonitoring = client.collectionMonitoring(settingSelector, requestContext); + collectionMonitoringList.add(collectionMonitoring); + } + } + + return collectionMonitoringList; + } + /** * 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..857ebc2294bd 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,155 @@ 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 final 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 + * Creates a new State with an incremented refresh attempt count. + * This method follows the immutable pattern by returning a new instance rather than + * modifying the current state. Used when a refresh fails to track attempts for backoff logic. + * @return a new State instance with refreshAttempt incremented by 1 */ - public void incrementRefreshAttempt() { - this.refreshAttempt += 1; + public State withIncrementedRefreshAttempt() { + return new State(this, this.nextRefreshCheck, this.refreshAttempt + 1); } /** - * @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..b74a7e3695e6 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.

+ * + *

Thread Safety: Uses ConcurrentHashMap for all state maps to ensure thread-safe access + * in multi-threaded environments.

+ */ 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,12 @@ public void setNextForcedRefresh(Duration refreshPeriod) { } /** + * 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. - * @param refreshInterval period between refresh checks. - * @param defaultMinBackoff min backoff between checks + * 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 +233,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.withIncrementedRefreshAttempt(), newRefresh); + } else { + updatedState = new State(storeState, newRefresh); } - State updatedState = new State(state, newRefresh); this.state.put(entry.getKey(), updatedState); } } @@ -181,7 +256,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 57% 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..782efef549f5 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,21 @@ // 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; } /** @@ -35,15 +35,15 @@ public void setSettingSelector(SettingSelector settingSelector) { /** * @return the featureFlags */ - 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..ba08e874d2cf 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,7 @@ 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 +190,7 @@ 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 +210,7 @@ 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 +228,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 +254,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); @@ -507,7 +512,7 @@ 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 +538,7 @@ 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); @@ -589,4 +594,149 @@ private List generateFeatureFlagWatchKeys() { watchKeys.add(currentWatchKey); return watchKeys; } + + @Test + public void refreshAllWithCollectionMonitoringTest(TestInfo testInfo) { + // Test that when refreshAll is enabled, collection monitoring is 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 collection monitoring state + WatchedConfigurationSettings collectionMonitoring = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null); + State state = new State(null, List.of(collectionMonitoring), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + // Config Store returns a change via collection monitoring + 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 (collection monitoring 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, collection monitoring is 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 collection monitoring + WatchedConfigurationSettings collectionMonitoring = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null); + State state = new State(null, List.of(collectionMonitoring), + 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 collectionMonitoringNoChangeTest(TestInfo testInfo) { + // Test that collection monitoring correctly detects 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 collectionMonitoring = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(collectionMonitoring), + 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 collectionMonitoringWithChangeDetectedTest(TestInfo testInfo) { + // Test that collection monitoring correctly detects 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 collectionMonitoring = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(collectionMonitoring), + 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..731d6dc0e715 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; @@ -170,7 +171,7 @@ 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); @@ -349,4 +350,67 @@ public void checkWatchKeysTest() { } } + @Test + public void collectionMonitoringTest() { + 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.collectionMonitoring(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 collectionMonitoringErrorTest() { + 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.collectionMonitoring(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(408); + assertThrows(AppConfigurationStatusException.class, + () -> client.collectionMonitoring(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(500); + assertThrows(AppConfigurationStatusException.class, + () -> client.collectionMonitoring(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(499); + assertThrows(HttpResponseException.class, + () -> client.collectionMonitoring(new SettingSelector(), contextMock)); + } + + @Test + public void collectionMonitoringUncheckedIOExceptionTest() { + 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.collectionMonitoring(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..31bcb5966f75 --- /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 java.time.Duration; +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.assertNotNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import static org.mockito.ArgumentMatchers.any; +import org.mockito.Mock; +import org.mockito.Mockito; +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 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 collectionMonitoringMock; + + 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 createCollectionMonitoringWithSingleSelectorTest() throws Exception { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mocks + when(clientMock.collectionMonitoring(any(SettingSelector.class), any(Context.class))) + .thenReturn(collectionMonitoringMock); + + // 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)).collectionMonitoring(selectorCaptor.capture(), any(Context.class)); + + SettingSelector capturedSelector = selectorCaptor.getValue(); + assertEquals(KEY_FILTER + "*", capturedSelector.getKeyFilter()); + assertEquals(LABEL_FILTER, capturedSelector.getLabelFilter()); + } + + @Test + public void createCollectionMonitoringWithMultipleSelectorsTest() 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.collectionMonitoring(any(SettingSelector.class), any(Context.class))) + .thenReturn(collectionMonitoringMock); + + // Test + AzureAppConfigDataLoader loader = createLoader(); + List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + + // Verify - should create collection monitoring for both selectors + assertNotNull(result); + assertEquals(2, result.size()); + verify(clientMock, times(2)).collectionMonitoring(any(SettingSelector.class), any(Context.class)); + } + + @Test + public void createCollectionMonitoringSkipsSnapshotsTest() 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.collectionMonitoring(any(SettingSelector.class), any(Context.class))) + .thenReturn(collectionMonitoringMock); + + // 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)).collectionMonitoring(any(SettingSelector.class), any(Context.class)); + } + + @Test + public void createCollectionMonitoringWithMultipleLabelsTest() 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.collectionMonitoring(any(SettingSelector.class), any(Context.class))) + .thenReturn(collectionMonitoringMock); + + // Test + AzureAppConfigDataLoader loader = createLoader(); + List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + + // Verify - should create collection monitoring for each label + assertNotNull(result); + assertEquals(3, result.size()); + verify(clientMock, times(3)).collectionMonitoring(any(SettingSelector.class), any(Context.class)); + } + + @Test + public void refreshAllEnabledUsesCollectionMonitoringTest() 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.collectionMonitoring(any(SettingSelector.class), any(Context.class))) + .thenReturn(collectionMonitoringMock); + + // Test - verify that collection monitoring is created when refreshAll is enabled + AzureAppConfigDataLoader loader = createLoader(); + List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + + // Verify collection monitoring was created + assertNotNull(result); + assertEquals(1, result.size()); + verify(clientMock, times(1)).collectionMonitoring(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..1a4f75d4c150 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; @@ -80,14 +80,14 @@ 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, + 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()); } @@ -95,15 +95,15 @@ public void loadFeatureFlagsTestNoFeatureFlags() { public void loadFeatureFlagsTestFeatureFlags() { List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false), new FeatureFlagConfigurationSetting("Beta", true)); - 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, + 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()); } @@ -111,27 +111,27 @@ public void loadFeatureFlagsTestFeatureFlags() { public void loadFeatureFlagsTestMultipleLoads() { List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false), new FeatureFlagConfigurationSetting("Beta", true)); - 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, + 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); + 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 +170,14 @@ 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, + 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()); } 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..9cb7b035a6c5 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(); + } + }