diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java index d25e616ced98..a7e4d24d4ae3 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java @@ -17,8 +17,8 @@ import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore; @@ -30,6 +30,18 @@ public class AppConfigurationRefreshUtil { private static final Logger LOGGER = LoggerFactory.getLogger(AppConfigurationRefreshUtil.class); + private static final String FEATURE_FLAG_PREFIX = ".appconfig.featureflag/*"; + + /** + * Functional interface for refresh operations that can throw AppConfigurationStatusException. + */ + @FunctionalInterface + private interface RefreshOperation { + + void execute(AppConfigurationReplicaClient client, RefreshEventData eventData, Context context) + throws AppConfigurationStatusException; + } + /** * Checks all configured stores to determine if any configurations need to be refreshed. * @@ -67,66 +79,46 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF if ((notification.getPrimaryToken() != null && StringUtils.hasText(notification.getPrimaryToken().getName())) || (notification.getSecondaryToken() != null - && StringUtils.hasText(notification.getPrimaryToken().getName()))) { + && StringUtils.hasText(notification.getSecondaryToken().getName()))) { pushRefresh = true; } Context context = new Context("refresh", true).addData(PUSH_REFRESH, pushRefresh); - - clientFactory.findActiveClients(originEndpoint); - AppConfigurationReplicaClient client = clientFactory.getNextActiveClient(originEndpoint, false); + clientFactory.findActiveClients(originEndpoint); if (monitor.isEnabled() && StateHolder.getLoadState(originEndpoint)) { - while (client != null) { - try { - refreshWithTime(client, StateHolder.getState(originEndpoint), monitor.getRefreshInterval(), - eventData, replicaLookUp, context); - if (eventData.getDoRefresh()) { - clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint()); - return eventData; - } - // If check didn't throw an error other clients don't need to be checked. - break; - } catch (HttpResponseException e) { - LOGGER.warn( - "Failed to connect to App Configuration store {} during configuration refresh check. " - + "Status: {}, Message: {}", - client.getEndpoint(), e.getResponse().getStatusCode(), e.getMessage()); - - clientFactory.backoffClient(originEndpoint, client.getEndpoint()); - client = clientFactory.getNextActiveClient(originEndpoint, false); - } + RefreshEventData result = executeRefreshWithRetry( + clientFactory, + originEndpoint, + (client, data, ctx) -> refreshWithTime(client, StateHolder.getState(originEndpoint), + monitor.getRefreshInterval(), data, replicaLookUp, ctx), + eventData, + context, + "configuration refresh check"); + if (result != null) { + return result; } } else { - LOGGER.debug("Skipping configuration refresh check for " + originEndpoint); + LOGGER.debug("Skipping configuration refresh check for {}", originEndpoint); } FeatureFlagStore featureStore = connection.getFeatureFlagStore(); if (featureStore.getEnabled() && StateHolder.getStateFeatureFlag(originEndpoint) != null) { - client = clientFactory.getNextActiveClient(originEndpoint, false); - while (client != null) { - try { - refreshWithTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(originEndpoint), - monitor.getFeatureFlagRefreshInterval(), eventData, replicaLookUp, context); - if (eventData.getDoRefresh()) { - clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint()); - return eventData; - } - // If check didn't throw an error other clients don't need to be checked. - break; - } catch (HttpResponseException e) { - LOGGER.warn( - "Failed to connect to App Configuration store {} during feature flag refresh check. " - + "Status: {}, Message: {}", - client.getEndpoint(), e.getResponse().getStatusCode(), e.getMessage()); - - clientFactory.backoffClient(originEndpoint, client.getEndpoint()); - client = clientFactory.getNextActiveClient(originEndpoint, false); - } + RefreshEventData result = executeRefreshWithRetry( + clientFactory, + originEndpoint, + (client, data, ctx) -> refreshWithTimeFeatureFlags(client, + StateHolder.getStateFeatureFlag(originEndpoint), + monitor.getFeatureFlagRefreshInterval(), data, replicaLookUp, ctx), + eventData, + context, + "feature flag refresh check"); + if (result != null) { + return result; } } else { - LOGGER.debug("Skipping feature flag refresh check for " + originEndpoint); + LOGGER.debug("Skipping feature flag refresh check for {}", originEndpoint); } } @@ -138,6 +130,47 @@ RefreshEventData refreshStoresCheck(AppConfigurationReplicaClientFactory clientF return eventData; } + /** + * Executes a refresh operation with automatic retry logic across replica clients. + * + * @param clientFactory factory for accessing App Configuration clients + * @param originEndpoint the endpoint of the origin configuration store + * @param operation the refresh operation to execute + * @param eventData the refresh event data to update + * @param context the operation context + * @param checkType description of the check type for logging (e.g., "configuration refresh check") + * @return the eventData if refresh is needed, null otherwise + */ + private RefreshEventData executeRefreshWithRetry( + AppConfigurationReplicaClientFactory clientFactory, + String originEndpoint, + RefreshOperation operation, + RefreshEventData eventData, + Context context, + String checkType) { + AppConfigurationReplicaClient client = clientFactory.getNextActiveClient(originEndpoint, false); + + while (client != null) { + try { + operation.execute(client, eventData, context); + if (eventData.getDoRefresh()) { + clientFactory.setCurrentConfigStoreClient(originEndpoint, client.getEndpoint()); + return eventData; + } + // If check didn't throw an error, other clients don't need to be checked. + break; + } catch (HttpResponseException e) { + LOGGER.warn( + "Failed to connect to App Configuration store {} during {}. Status: {}, Message: {}", + client.getEndpoint(), checkType, e.getResponse().getStatusCode(), e.getMessage()); + + clientFactory.backoffClient(originEndpoint, client.getEndpoint()); + client = clientFactory.getNextActiveClient(originEndpoint, false); + } + } + return null; + } + /** * Performs a refresh check for a specific store client without time constraints. This method is used for refresh * failure scenarios only. @@ -158,7 +191,7 @@ static boolean refreshStoreCheck(AppConfigurationReplicaClient client, String or /** * Performs a feature flag refresh check for a specific store client. This method is used for refresh failure * scenarios only. - * + * * @param featureStoreEnabled whether feature store is enabled * @param client the client for checking refresh status * @param context the operation context @@ -172,7 +205,7 @@ static boolean refreshStoreFeatureFlagCheck(Boolean featureStoreEnabled, if (featureStoreEnabled && StateHolder.getStateFeatureFlag(endpoint) != null) { refreshWithoutTimeFeatureFlags(client, StateHolder.getStateFeatureFlag(endpoint), eventData, context); } else { - LOGGER.debug("Skipping feature flag refresh check for " + endpoint); + LOGGER.debug("Skipping feature flag refresh check for {}", endpoint); } return eventData.getDoRefresh(); } @@ -194,7 +227,15 @@ private static void refreshWithTime(AppConfigurationReplicaClient client, State throws AppConfigurationStatusException { if (Instant.now().isAfter(state.getNextRefreshCheck())) { replicaLookUp.updateAutoFailoverEndpoints(); - refreshWithoutTime(client, state.getWatchKeys(), eventData, context); + + // Check watched configuration settings first if configured + List watchedSettings = state.getCollectionWatchKeys(); + if (watchedSettings != null && !watchedSettings.isEmpty()) { + refreshWithoutTimeWatchedConfigurationSettings(client, watchedSettings, eventData, context); + } else { + // Fall back to traditional watch key monitoring + refreshWithoutTime(client, state.getWatchKeys(), eventData, context); + } StateHolder.getCurrentState().updateStateRefresh(state, refreshInterval); } @@ -226,6 +267,34 @@ private static void refreshWithoutTime(AppConfigurationReplicaClient client, Lis } } + /** + * Checks configuration watched configuration settings for etag changes without time validation. This method + * immediately checks all watched configuration settings selectors for changes regardless of refresh intervals. + * + * @param client the App Configuration client to use for checking + * @param watchedSettings the list of watched configuration settings to watch for changes + * @param eventData the refresh event data to update if changes are detected + * @param context the operation context + * @throws AppConfigurationStatusException if there's an error during the refresh check + */ + private static void refreshWithoutTimeWatchedConfigurationSettings(AppConfigurationReplicaClient client, + List watchedSettings, RefreshEventData eventData, Context context) + throws AppConfigurationStatusException { + for (WatchedConfigurationSettings watchedSetting : watchedSettings) { + if (client.checkWatchKeys(watchedSetting.getSettingSelector(), context)) { + String eventDataInfo = watchedSetting.getSettingSelector().getKeyFilter(); + + // Only one refresh event needs to be called to update all of the + // stores, not one for each. + LOGGER.info("Configuration Refresh Event triggered by watched configuration settings: {}", + eventDataInfo); + + eventData.setMessage(eventDataInfo); + return; + } + } + } + /** * Checks feature flag refresh triggers with time-based validation. Only performs the refresh check if the refresh * interval has elapsed. @@ -245,15 +314,13 @@ private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient cl if (date.isAfter(state.getNextRefreshCheck())) { replicaLookUp.updateAutoFailoverEndpoints(); - for (FeatureFlags featureFlags : state.getWatchKeys()) { + for (WatchedConfigurationSettings featureFlags : state.getWatchKeys()) { if (client.checkWatchKeys(featureFlags.getSettingSelector(), context)) { - String eventDataInfo = ".appconfig.featureflag/*"; - - // Only one refresh Event needs to be call to update all of the + // Only one refresh event needs to be called to update all of the // stores, not one for each. - LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo); + LOGGER.info("Configuration Refresh Event triggered by {}", FEATURE_FLAG_PREFIX); - eventData.setMessage(eventDataInfo); + eventData.setMessage(FEATURE_FLAG_PREFIX); return; } @@ -276,15 +343,13 @@ private static void refreshWithTimeFeatureFlags(AppConfigurationReplicaClient cl private static void refreshWithoutTimeFeatureFlags(AppConfigurationReplicaClient client, FeatureFlagState watchKeys, RefreshEventData eventData, Context context) throws AppConfigurationStatusException { - for (FeatureFlags featureFlags : watchKeys.getWatchKeys()) { + for (WatchedConfigurationSettings featureFlags : watchKeys.getWatchKeys()) { if (client.checkWatchKeys(featureFlags.getSettingSelector(), context)) { - String eventDataInfo = ".appconfig.featureflag/*"; - - // Only one refresh Event needs to be call to update all of the + // Only one refresh event needs to be called to update all of the // stores, not one for each. - LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo); + LOGGER.info("Configuration Refresh Event triggered by {}", FEATURE_FLAG_PREFIX); - eventData.setMessage(eventDataInfo); + eventData.setMessage(FEATURE_FLAG_PREFIX); } } @@ -292,7 +357,7 @@ private static void refreshWithoutTimeFeatureFlags(AppConfigurationReplicaClient /** * Checks the etag values between watched and current configuration settings to determine if a refresh is needed. - * + * * @param watchSetting the configuration setting being watched for changes * @param currentTriggerConfiguration the current configuration setting from the store * @param endpoint the endpoint of the configuration store @@ -313,9 +378,9 @@ private static void checkETag(ConfigurationSetting watchSetting, ConfigurationSe String eventDataInfo = watchSetting.getKey(); - // Only one refresh Event needs to be call to update all of the + // Only one refresh event needs to be called to update all of the // stores, not one for each. - LOGGER.info("Configuration Refresh Event triggered by " + eventDataInfo); + LOGGER.info("Configuration Refresh Event triggered by {}", eventDataInfo); eventData.setMessage(eventDataInfo); } } @@ -325,7 +390,7 @@ private static void checkETag(ConfigurationSetting watchSetting, ConfigurationSe */ static class RefreshEventData { - private static final String MSG_TEMPLATE = "Some keys matching %s has been updated since last check."; + private static final String MSG_TEMPLATE = "Some keys matching %s have been updated since last check."; private String message; @@ -340,7 +405,7 @@ static class RefreshEventData { /** * Sets the refresh message using the standard message template. - * + * * @param prefix the prefix to include in the message (typically a key name) * @return this RefreshEventData instance for method chaining */ @@ -351,7 +416,7 @@ RefreshEventData setMessage(String prefix) { /** * Sets the full refresh message and marks that a refresh should occur. - * + * * @param message the complete message describing the refresh event * @return this RefreshEventData instance for method chaining */ @@ -363,7 +428,7 @@ private RefreshEventData setFullMessage(String message) { /** * Gets the refresh event message. - * + * * @return the message describing what triggered the refresh */ public String getMessage() { @@ -372,7 +437,7 @@ public String getMessage() { /** * Indicates whether a refresh should be performed. - * + * * @return true if a refresh is needed, false otherwise */ public boolean getDoRefresh() { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java index 0cde712d5867..049e0ed65546 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClient.java @@ -21,7 +21,7 @@ import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.data.appconfiguration.models.SnapshotComposition; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import io.netty.handler.codec.http.HttpResponseStatus; @@ -150,15 +150,47 @@ List listSettings(SettingSelector settingSelector, Context } } + /** + * Gets configuration settings using watched configuration settings. This method retrieves all settings matching the + * selector and captures ETags for collection-based refresh monitoring. + * + * @param settingSelector selector criteria for configuration settings + * @param context Azure SDK context for request correlation + * @return WatchedConfigurationSettings containing the retrieved configuration settings and match conditions + * @throws HttpResponseException if the request fails + */ + WatchedConfigurationSettings loadWatchedSettings(SettingSelector settingSelector, Context context) { + List configurationSettings = new ArrayList<>(); + List checks = new ArrayList<>(); + try { + client.listConfigurationSettings(settingSelector, context).streamByPage().forEach(pagedResponse -> { + checks.add( + new MatchConditions().setIfNoneMatch(pagedResponse.getHeaders().getValue(HttpHeaderName.ETAG))); + for (ConfigurationSetting setting : pagedResponse.getValue()) { + configurationSettings.add(NormalizeNull.normalizeNullLabel(setting)); + } + }); + + // Needs to happen after or we don't know if the request succeeded or failed. + this.failedAttempts = 0; + settingSelector.setMatchConditions(checks); + return new WatchedConfigurationSettings(settingSelector, configurationSettings); + } catch (HttpResponseException e) { + throw handleHttpResponseException(e); + } catch (UncheckedIOException e) { + throw new AppConfigurationStatusException(e.getMessage(), null, null); + } + } + /** * Lists feature flags from the Azure App Configuration store. * * @param settingSelector selector criteria for feature flags * @param context Azure SDK context for request correlation - * @return FeatureFlags containing the retrieved feature flags and match conditions + * @return WatchedConfigurationSettings containing the retrieved feature flags and match conditions * @throws HttpResponseException if the request fails */ - FeatureFlags listFeatureFlags(SettingSelector settingSelector, Context context) + WatchedConfigurationSettings listFeatureFlags(SettingSelector settingSelector, Context context) throws HttpResponseException { List configurationSettings = new ArrayList<>(); List checks = new ArrayList<>(); @@ -175,7 +207,7 @@ FeatureFlags listFeatureFlags(SettingSelector settingSelector, Context context) // Needs to happen after or we don't know if the request succeeded or failed. this.failedAttempts = 0; settingSelector.setMatchConditions(checks); - return new FeatureFlags(settingSelector, configurationSettings); + return new WatchedConfigurationSettings(settingSelector, configurationSettings); } catch (HttpResponseException e) { throw handleHttpResponseException(e); } catch (UncheckedIOException e) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java index e585997e05a5..8423ec60e63e 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationSnapshotPropertySource.java @@ -10,7 +10,7 @@ import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; /** * Azure App Configuration PropertySource unique per Store Label(Profile) combo. @@ -49,7 +49,7 @@ final class AppConfigurationSnapshotPropertySource extends AppConfigurationAppli public void initProperties(List trim, Context context) throws InvalidConfigurationPropertyValueException { processConfigurationSettings(replicaClient.listSettingSnapshot(snapshotName, context), null, trim); - FeatureFlags featureFlags = new FeatureFlags(null, featureFlagsList); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, featureFlagsList); featureFlagClient.proccessFeatureFlags(featureFlags, replicaClient.getEndpoint()); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoader.java index 7cd92ea3387b..bca6a57646a9 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,11 +7,12 @@ 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; -import org.springframework.boot.bootstrap.BootstrapRegistry.InstanceSupplier; import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.bootstrap.BootstrapRegistry.InstanceSupplier; import org.springframework.boot.context.config.ConfigDataLoader; import org.springframework.boot.context.config.ConfigDataLoaderContext; import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; @@ -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,23 @@ public ConfigData load(ConfigDataLoaderContext context, AzureAppConfigDataResour monitoring.getFeatureFlagRefreshInterval()); if (monitoring.isEnabled()) { - // Setting new ETag values for Watch - List watchKeysSettings = monitoring.getTriggers().stream() - .map(trigger -> currentClient.getWatchKey(trigger.getKey(), trigger.getLabel(), - requestContext)) - .toList(); - - storeState.setState(resource.getEndpoint(), watchKeysSettings, monitoring.getRefreshInterval()); + // Check if refreshAll is enabled - if so, use watched configuration settings + if (monitoring.getTriggers().size() == 0) { + // Use watched configuration settings for refresh + List watchedConfigurationSettingsList = getWatchedConfigurationSettings( + currentClient); + storeState.setState(resource.getEndpoint(), Collections.emptyList(), + watchedConfigurationSettingsList, monitoring.getRefreshInterval()); + } else { + // Use traditional watch key monitoring + List watchKeysSettings = monitoring.getTriggers().stream() + .map(trigger -> currentClient.getWatchKey(trigger.getKey(), trigger.getLabel(), + requestContext)) + .toList(); + + storeState.setState(resource.getEndpoint(), watchKeysSettings, + monitoring.getRefreshInterval()); + } } storeState.setLoadState(resource.getEndpoint(), true); // Success - configuration loaded, exit loop lastException = null; @@ -260,16 +272,16 @@ private List createSettings(AppConfigurationRepl * Creates a list of feature flags from Azure App Configuration. * * @param client client for connecting to App Configuration - * @return a list of FeatureFlags + * @return a list of WatchedConfigurationSettings * @throws Exception creating feature flags failed */ - private List createFeatureFlags(AppConfigurationReplicaClient client) + private List createFeatureFlags(AppConfigurationReplicaClient client) throws Exception { - List featureFlagWatchKeys = new ArrayList<>(); + List featureFlagWatchKeys = new ArrayList<>(); List profiles = resource.getProfiles().getActive(); for (FeatureFlagKeyValueSelector selectedKeys : resource.getFeatureFlagSelects()) { - List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client, + List storesFeatureFlags = featureFlagClient.loadFeatureFlags(client, selectedKeys.getKeyFilter(), selectedKeys.getLabelFilter(profiles), requestContext); featureFlagWatchKeys.addAll(storesFeatureFlags); } @@ -277,6 +289,41 @@ private List createFeatureFlags(AppConfigurationReplicaClient clie return featureFlagWatchKeys; } + /** + * Creates a list of watched configuration settings for configuration settings from Azure App Configuration. This is + * used for collection-based refresh monitoring as an alternative to individual watch keys. + * + * @param client client for connecting to App Configuration + * @return a list of WatchedConfigurationSettings for configuration settings + * @throws Exception creating watched configuration settings failed + */ + private List getWatchedConfigurationSettings(AppConfigurationReplicaClient client) + throws Exception { + List watchedConfigurationSettingsList = new ArrayList<>(); + List selects = resource.getSelects(); + List profiles = resource.getProfiles().getActive(); + + for (AppConfigurationKeyValueSelector selectedKeys : selects) { + // Skip snapshots - they don't support watched configuration settings + if (StringUtils.hasText(selectedKeys.getSnapshotName())) { + continue; + } + + // Create watched configuration settings for each label + for (String label : selectedKeys.getLabelFilter(profiles)) { + SettingSelector settingSelector = new SettingSelector() + .setKeyFilter(selectedKeys.getKeyFilter() + "*") + .setLabelFilter(label); + + WatchedConfigurationSettings watchedConfigurationSettings = client.loadWatchedSettings(settingSelector, + requestContext); + watchedConfigurationSettingsList.add(watchedConfigurationSettings); + } + } + + return watchedConfigurationSettingsList; + } + /** * Logs a replica failure with contextual information about the failure scenario and available replicas. * diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java index 44cb297b5e27..85ddcf4b54ef 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClient.java @@ -37,7 +37,7 @@ import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagFilter; import com.azure.data.appconfiguration.models.SettingSelector; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Allocation; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.FeatureTelemetry; @@ -78,9 +78,9 @@ class FeatureFlagClient { *

* */ - List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter, + List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, String customKeyFilter, String[] labelFilter, Context context) { - List loadedFeatureFlags = new ArrayList<>(); + List loadedFeatureFlags = new ArrayList<>(); String keyFilter = SELECT_ALL_FEATURE_FLAGS; @@ -95,18 +95,15 @@ List loadFeatureFlags(AppConfigurationReplicaClient replicaClient, SettingSelector settingSelector = new SettingSelector().setKeyFilter(keyFilter).setLabelFilter(label); context.addData("FeatureFlagTracing", tracing); - FeatureFlags features = replicaClient.listFeatureFlags(settingSelector, context); - loadedFeatureFlags.addAll(proccessFeatureFlags(features, replicaClient.getOriginClient())); + WatchedConfigurationSettings features = replicaClient.listFeatureFlags(settingSelector, context); + loadedFeatureFlags.add(proccessFeatureFlags(features, replicaClient.getOriginClient())); } return loadedFeatureFlags; } - List proccessFeatureFlags(FeatureFlags features, String endpoint) { - List loadedFeatureFlags = new ArrayList<>(); - loadedFeatureFlags.add(features); - + WatchedConfigurationSettings proccessFeatureFlags(WatchedConfigurationSettings features, String endpoint) { // Reading In Features - for (ConfigurationSetting setting : features.getFeatureFlags()) { + for (ConfigurationSetting setting : features.getConfigurationSettings()) { if (setting instanceof FeatureFlagConfigurationSetting && FEATURE_FLAG_CONTENT_TYPE.equals(setting.getContentType())) { FeatureFlagConfigurationSetting featureFlag = (FeatureFlagConfigurationSetting) setting; @@ -114,7 +111,7 @@ List proccessFeatureFlags(FeatureFlags features, String endpoint) properties.put(featureFlag.getKey(), createFeature(featureFlag, endpoint)); } } - return loadedFeatureFlags; + return features; } /** diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java index 1d237001e78e..59a1b95150e9 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/State.java @@ -6,72 +6,159 @@ import java.util.List; import com.azure.data.appconfiguration.models.ConfigurationSetting; - +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; + +/** + * Immutable representation of the refresh state for an Azure App Configuration store. + * + *

+ * Holds configuration watch keys, collection monitoring settings, refresh timing, and attempt tracking for a single + * configuration store endpoint. All fields are final to ensure thread-safety and immutability. + *

+ * + *

+ * State changes are made by creating new instances rather than mutating existing ones, following an immutable design + * pattern. + *

+ */ class State { + /** Configuration settings used as watch keys to trigger refresh events. */ private final List watchKeys; + /** Collection monitoring configurations that can trigger refresh events. */ + private final List collectionWatchKeys; + + /** The next time this store should be checked for refresh. */ private final Instant nextRefreshCheck; + /** The endpoint URL of the configuration store. */ private final String originEndpoint; - private Integer refreshAttempt; + /** Number of refresh attempts for exponential backoff calculation. */ + private int refreshAttempt; + /** The refresh interval in seconds. */ private final int refreshInterval; + /** + * Creates a new State for configuration watch keys without collection monitoring. + * @param watchKeys list of configuration watch keys that can trigger a refresh event + * @param refreshInterval refresh interval in seconds + * @param originEndpoint the endpoint URL of the configuration store + */ State(List watchKeys, int refreshInterval, String originEndpoint) { - this.watchKeys = watchKeys; - this.refreshInterval = refreshInterval; - nextRefreshCheck = Instant.now().plusSeconds(refreshInterval); - this.originEndpoint = originEndpoint; - this.refreshAttempt = 1; + this(watchKeys, null, refreshInterval, originEndpoint); } + /** + * Creates a new State with both configuration watch keys and collection monitoring. Sets the initial refresh + * attempt to 1 and calculates next refresh time from now. + * @param watchKeys list of configuration watch keys that can trigger a refresh event + * @param collectionWatchKeys list of collection monitoring configurations that can trigger a refresh event + * @param refreshInterval refresh interval in seconds + * @param originEndpoint the endpoint URL of the configuration store + */ + State(List watchKeys, List collectionWatchKeys, + int refreshInterval, String originEndpoint) { + this(watchKeys, collectionWatchKeys, refreshInterval, originEndpoint, + Instant.now().plusSeconds(refreshInterval), 1); + } + + /** + * Creates a new State from an existing state with an updated refresh time. Preserves the current refresh attempt + * count. + * @param oldState the existing State to copy from + * @param newRefresh the new refresh time + */ State(State oldState, Instant newRefresh) { - this.watchKeys = oldState.getWatchKeys(); - this.refreshInterval = oldState.getRefreshInterval(); - this.nextRefreshCheck = newRefresh; - this.originEndpoint = oldState.getOriginEndpoint(); - this.refreshAttempt = oldState.getRefreshAttempt(); + this(oldState, newRefresh, oldState.getRefreshAttempt()); + } + + /** + * Creates a new State from an existing state with updated refresh time and attempt count. Used when creating states + * with modified refresh attempts for backoff logic. + * @param oldState the existing State to copy from + * @param newRefresh the new refresh time + * @param refreshAttempt the refresh attempt count + */ + State(State oldState, Instant newRefresh, int refreshAttempt) { + this(oldState.getWatchKeys(), oldState.getCollectionWatchKeys(), oldState.getRefreshInterval(), + oldState.getOriginEndpoint(), newRefresh, refreshAttempt); + } + + /** + * Primary constructor that initializes all fields. All other constructors delegate to this one. This constructor is + * private to enforce the use of the public factory-style constructors. + * @param watchKeys list of configuration watch keys + * @param collectionWatchKeys list of collection monitoring configurations (may be null) + * @param refreshInterval refresh interval in seconds + * @param originEndpoint the endpoint URL of the configuration store + * @param nextRefreshCheck the next time to check for refresh + * @param refreshAttempt the current refresh attempt count + */ + private State(List watchKeys, List collectionWatchKeys, + int refreshInterval, String originEndpoint, Instant nextRefreshCheck, int refreshAttempt) { + this.watchKeys = watchKeys; + this.collectionWatchKeys = collectionWatchKeys; + this.refreshInterval = refreshInterval; + this.nextRefreshCheck = nextRefreshCheck; + this.originEndpoint = originEndpoint; + this.refreshAttempt = refreshAttempt; } /** - * @return the watchKeys + * Gets the configuration settings used as watch keys for this store. + * @return the list of configuration watch keys */ public List getWatchKeys() { return watchKeys; } /** - * @return the nextRefreshCheck + * Gets the collection monitoring configurations for this store. + * @return the list of collection monitoring configurations, or null if not configured + */ + public List getCollectionWatchKeys() { + return collectionWatchKeys; + } + + /** + * Gets the next time this store should be checked for refresh. + * @return the Instant of the next refresh check */ public Instant getNextRefreshCheck() { return nextRefreshCheck; } /** - * @return the originEndpoint + * Gets the endpoint URL of the configuration store. + * @return the origin endpoint */ public String getOriginEndpoint() { return originEndpoint; } /** - * @return the refreshAttempt + * Gets the number of refresh attempts. Used for exponential backoff calculation. + * @return the refresh attempt count */ - public Integer getRefreshAttempt() { + public int getRefreshAttempt() { return refreshAttempt; } /** - * Adds 1 to the number of refresh attempts + * Increments refresh attempt count. Used when a refresh fails to track attempts for backoff logic. + * @return the State instance with refreshAttempt incremented by 1 */ - public void incrementRefreshAttempt() { + public State incrementRefreshAttempt() { this.refreshAttempt += 1; + return this; } /** - * @return the refreshInterval + * Gets the refresh interval for this store. + * @return the refresh interval in seconds */ public int getRefreshInterval() { return refreshInterval; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java index 8d63ce85ac92..e9ff389e6570 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java @@ -11,95 +11,163 @@ import java.util.concurrent.ConcurrentHashMap; import com.azure.data.appconfiguration.models.ConfigurationSetting; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +/** + * Thread-safe singleton holder for managing refresh state of Azure App Configuration stores. + * + *

+ * Maintains state for configuration settings, feature flags, and refresh intervals across multiple configuration + * stores. Implements exponential backoff for failed refresh attempts and coordinates the timing of refresh operations. + *

+ */ final class StateHolder { + /** Maximum jitter in seconds to add when expiring state to prevent thundering herd. */ private static final int MAX_JITTER = 15; + /** The current singleton instance of StateHolder. */ private static StateHolder currentState; + /** Map of configuration store endpoints to their refresh state. */ private final Map state = new ConcurrentHashMap<>(); + /** Map of configuration store endpoints to their feature flag refresh state. */ private final Map featureFlagState = new ConcurrentHashMap<>(); + /** Map tracking whether each configuration store has been successfully loaded. */ private final Map loadState = new ConcurrentHashMap<>(); + /** Number of client-level refresh attempts for backoff calculation. */ private Integer clientRefreshAttempts = 1; + /** The next time a forced refresh should occur across all stores. */ private Instant nextForcedRefresh; StateHolder() { } + /** + * Gets the current singleton instance of StateHolder. + * @return the current StateHolder instance, or null if not yet initialized + */ static StateHolder getCurrentState() { return currentState; } + /** + * Updates the singleton instance to a new StateHolder. + * @param newState the new StateHolder instance to set as current + * @return the updated StateHolder instance + */ static StateHolder updateState(StateHolder newState) { currentState = newState; return currentState; } /** + * Retrieves the refresh state for a specific configuration store. * @param originEndpoint the endpoint for the origin config store - * @return the state + * @return the State for the specified store, or null if not found */ static State getState(String originEndpoint) { return currentState.getFullState().get(originEndpoint); } + /** + * Gets the full map of configuration store states. + * @return map of endpoint to State + */ private Map getFullState() { return state; } + /** + * Gets the full map of feature flag states. + * @return map of endpoint to FeatureFlagState + */ private Map getFullFeatureFlagState() { return featureFlagState; } + /** + * Gets the full map of load states. + * @return map of endpoint to load status + */ private Map getFullLoadState() { return loadState; } /** + * Retrieves the feature flag refresh state for a specific configuration store. * @param originEndpoint the endpoint for the origin config store - * @return the state + * @return the FeatureFlagState for the specified store, or null if not found */ static FeatureFlagState getStateFeatureFlag(String originEndpoint) { return currentState.getFullFeatureFlagState().get(originEndpoint); } /** - * @param originEndpoint the stores origin endpoint + * Sets the refresh state for a configuration store. + * @param originEndpoint the store's origin endpoint * @param watchKeys list of configuration watch keys that can trigger a refresh event - * @param duration refresh duration. + * @param duration refresh duration */ void setState(String originEndpoint, List watchKeys, Duration duration) { state.put(originEndpoint, new State(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint)); } /** - * @param originEndpoint the stores origin endpoint + * Sets the refresh state for a configuration store with collection monitoring. + * @param originEndpoint the store's origin endpoint * @param watchKeys list of configuration watch keys that can trigger a refresh event - * @param duration refresh duration. + * @param collectionWatchKeys list of collection monitoring configurations that can trigger a refresh event + * @param duration refresh duration + */ + void setState(String originEndpoint, List watchKeys, + List collectionWatchKeys, Duration duration) { + state.put(originEndpoint, + new State(watchKeys, collectionWatchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint)); + } + + /** + * Sets the feature flag refresh state for a configuration store. + * @param originEndpoint the store's origin endpoint + * @param watchKeys list of feature flag watch keys that can trigger a refresh event + * @param duration refresh duration */ - void setStateFeatureFlag(String originEndpoint, List watchKeys, + void setStateFeatureFlag(String originEndpoint, List watchKeys, Duration duration) { featureFlagState.put(originEndpoint, new FeatureFlagState(watchKeys, Math.toIntExact(duration.getSeconds()), originEndpoint)); } + /** + * Updates the configuration state with a new refresh time based on the duration. + * @param state the current State to update + * @param duration the duration to add to the current time for the next refresh + */ void updateStateRefresh(State state, Duration duration) { this.state.put(state.getOriginEndpoint(), new State(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds())))); } + /** + * Updates the feature flag state with a new refresh time based on the duration. + * @param state the current FeatureFlagState to update + * @param duration the duration to add to the current time for the next refresh + */ void updateFeatureFlagStateRefresh(FeatureFlagState state, Duration duration) { this.featureFlagState.put(state.getOriginEndpoint(), new FeatureFlagState(state, Instant.now().plusSeconds(Math.toIntExact(duration.getSeconds())))); } + /** + * Expires the state for a configuration store by setting a new refresh time with random jitter. The jitter helps + * prevent thundering herd when multiple stores refresh simultaneously. + * @param originEndpoint the endpoint of the store to expire + */ void expireState(String originEndpoint) { State oldState = state.get(originEndpoint); long wait = (long) (new SecureRandom().nextDouble() * MAX_JITTER); @@ -111,7 +179,9 @@ void expireState(String originEndpoint) { } /** - * @return the loadState + * Checks if a configuration store has been successfully loaded. + * @param originEndpoint the endpoint of the store to check + * @return true if the store has been loaded, false otherwise */ static boolean getLoadState(String originEndpoint) { return currentState.getFullLoadState().getOrDefault(originEndpoint, false); @@ -126,15 +196,16 @@ void setLoadState(String originEndpoint, Boolean loaded) { } /** - * @return the nextForcedRefresh + * Gets the next time a forced refresh should occur across all stores. + * @return the Instant of the next forced refresh, or null if not set */ public static Instant getNextForcedRefresh() { return currentState.nextForcedRefresh; } /** - * Set after load or refresh is successful. - * @param refreshPeriod the refreshPeriod to set + * Sets the next forced refresh time. Called after a successful load or refresh. + * @param refreshPeriod the duration from now until the next forced refresh; if null, no refresh is scheduled */ public void setNextForcedRefresh(Duration refreshPeriod) { if (refreshPeriod != null) { @@ -143,10 +214,11 @@ public void setNextForcedRefresh(Duration refreshPeriod) { } /** - * Sets a minimum value until the next refresh. If a refresh interval has passed or is smaller than the calculated - * backoff time, the refresh interval is set to the backoff time. - * @param refreshInterval period between refresh checks. - * @param defaultMinBackoff min backoff between checks + * Updates the next refresh time for all stores using exponential backoff on failures. Sets a minimum value until + * the next refresh. If a refresh interval has passed or is smaller than the calculated backoff time, the refresh + * interval is set to the backoff time. This prevents excessive refresh attempts during transient failures. + * @param refreshInterval period between refresh checks + * @param defaultMinBackoff minimum backoff duration between checks in seconds */ void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) { if (refreshInterval != null) { @@ -160,14 +232,16 @@ void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) { } for (Entry entry : state.entrySet()) { - State state = entry.getValue(); - Instant newRefresh = getNextRefreshCheck(state.getNextRefreshCheck(), - state.getRefreshAttempt(), (long) state.getRefreshInterval(), defaultMinBackoff); - - if (newRefresh.compareTo(entry.getValue().getNextRefreshCheck()) != 0) { - state.incrementRefreshAttempt(); + State storeState = entry.getValue(); + Instant newRefresh = getNextRefreshCheck(storeState.getNextRefreshCheck(), + storeState.getRefreshAttempt(), (long) storeState.getRefreshInterval(), defaultMinBackoff); + + State updatedState; + if (newRefresh.compareTo(storeState.getNextRefreshCheck()) != 0) { + updatedState = new State(storeState.incrementRefreshAttempt(), newRefresh); + } else { + updatedState = new State(storeState, newRefresh); } - State updatedState = new State(state, newRefresh); this.state.put(entry.getKey(), updatedState); } } @@ -181,7 +255,7 @@ void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) { * @param defaultMinBackoff min backoff between checks * @return new Refresh Date */ - private Instant getNextRefreshCheck(Instant nextRefreshCheck, Integer attempt, Long interval, + private Instant getNextRefreshCheck(Instant nextRefreshCheck, int attempt, Long interval, Long defaultMinBackoff) { // The refresh interval is only updated if it is expired. if (!Instant.now().isAfter(nextRefreshCheck)) { diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java similarity index 54% rename from sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java rename to sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java index 2a1380a3d907..20b09087087e 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlags.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/configuration/WatchedConfigurationSettings.java @@ -1,21 +1,22 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -package com.azure.spring.cloud.appconfiguration.config.implementation.feature; +package com.azure.spring.cloud.appconfiguration.config.implementation.configuration; import java.util.List; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.SettingSelector; -public class FeatureFlags { +public class WatchedConfigurationSettings { private SettingSelector settingSelector; - private List featureFlags; + private List configurationSettings; - public FeatureFlags(SettingSelector settingSelector, List featureFlags) { + public WatchedConfigurationSettings(SettingSelector settingSelector, + List configurationSettings) { this.settingSelector = settingSelector; - this.featureFlags = featureFlags; + this.configurationSettings = configurationSettings; } /** @@ -33,17 +34,17 @@ public void setSettingSelector(SettingSelector settingSelector) { } /** - * @return the featureFlags + * @return the configurationSettings */ - public List getFeatureFlags() { - return featureFlags; + public List getConfigurationSettings() { + return configurationSettings; } /** - * @param featureFlags the featureFlags to set + * @param configurations the configurations to set */ - public void setFeatureFlags(List featureFlags) { - this.featureFlags = featureFlags; + public void setConfigurationSettings(List configurations) { + this.configurationSettings = configurations; } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java index ddca37b32140..4df042e96d24 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/feature/FeatureFlagState.java @@ -5,15 +5,17 @@ import java.time.Instant; import java.util.List; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; + public class FeatureFlagState { - private final List watchKeys; + private final List watchKeys; private final Instant nextRefreshCheck; private final String originEndpoint; - public FeatureFlagState(List watchKeys, int refreshInterval, String originEndpoint) { + public FeatureFlagState(List watchKeys, int refreshInterval, String originEndpoint) { this.watchKeys = watchKeys; nextRefreshCheck = Instant.now().plusSeconds(refreshInterval); this.originEndpoint = originEndpoint; @@ -28,7 +30,7 @@ public FeatureFlagState(FeatureFlagState oldState, Instant newRefresh) { /** * @return the watchKeys */ - public List getWatchKeys() { + public List getWatchKeys() { return watchKeys; } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java index f90a3a573f12..02ae16106ec6 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoring.java @@ -37,7 +37,7 @@ public final class AppConfigurationStoreMonitoring { private List triggers = new ArrayList<>(); /** - * Validation tokens for push notificaiton requests. + * Validation tokens for push notification requests. */ private PushNotification pushNotification = new PushNotification(); @@ -120,7 +120,7 @@ public void setPushNotification(PushNotification pushNotification) { @PostConstruct void validateAndInit() { if (enabled) { - Assert.notEmpty(triggers, "Triggers need to be set if refresh is enabled."); + // Triggers are not required defaults to use collection monitoring if not set for (AppConfigurationStoreTrigger trigger : triggers) { trigger.validateAndInit(); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java index 36c6e0c2e381..6af627e6afad 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java @@ -2,15 +2,6 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL; -import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_PREFIX; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -18,6 +9,9 @@ import java.util.Map; import org.junit.jupiter.api.AfterEach; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; @@ -25,6 +19,9 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.Mockito; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import org.mockito.MockitoAnnotations; import org.mockito.MockitoSession; import org.mockito.quality.Strictness; @@ -33,10 +30,12 @@ import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.SettingSelector; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.EMPTY_LABEL; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.FEATURE_FLAG_PREFIX; import com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationRefreshUtil.RefreshEventData; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlagState; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.AccessToken; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring.PushNotification; @@ -143,7 +142,8 @@ public void refreshWithoutTimeWatchKeyConfigStoreWatchKeyNoChange(TestInfo testI when(clientMock.getEndpoint()).thenReturn(endpoint); FeatureFlagState newState = new FeatureFlagState( - List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), + List.of(new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store does return a watch key change. @@ -191,7 +191,8 @@ public void refreshWithoutTimeFeatureFlagNoChange(TestInfo testInfo) { when(clientMock.getEndpoint()).thenReturn(endpoint); FeatureFlagState newState = new FeatureFlagState( - List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), + List.of(new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); // Config Store doesn't return a watch key change. @@ -211,7 +212,8 @@ public void refreshWithoutTimeFeatureFlagEtagChanged(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; when(clientMock.getEndpoint()).thenReturn(endpoint); - FeatureFlags featureFlags = new FeatureFlags(new SettingSelector(), watchKeysFeatureFlags); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(new SettingSelector(), + watchKeysFeatureFlags); FeatureFlagState newState = new FeatureFlagState(List.of(featureFlags), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); @@ -229,7 +231,10 @@ public void refreshWithoutTimeFeatureFlagEtagChanged(TestInfo testInfo) { @Test public void refreshStoresCheckSettingsTestNotEnabled(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; - setupFeatureFlagLoad(); + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); @@ -252,7 +257,10 @@ public void refreshStoresCheckSettingsTestNotEnabled(TestInfo testInfo) { @Test public void refreshStoresCheckSettingsTestNotLoaded(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; - setupFeatureFlagLoad(); + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); State newState = new State(generateWatchKeys(), Math.toIntExact(Duration.ofMinutes(10).getSeconds()), endpoint); @@ -343,7 +351,7 @@ public void refreshStoresCheckSettingsTestRefreshTimeNoChange(TestInfo testInfo) Mockito.any(Context.class)); } } - + @Test public void refreshStoresPushRefreshEnabledPrimary(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; @@ -380,7 +388,7 @@ public void refreshStoresPushRefreshEnabledPrimary(TestInfo testInfo) { assertTrue((Boolean) testContext.getData("PushRefresh").get()); } } - + @Test public void refreshStoresPushRefreshEnabledSecondary(TestInfo testInfo) { endpoint = testInfo.getDisplayName() + ".azconfig.io"; @@ -425,7 +433,8 @@ public void refreshStoresCheckSettingsTestTriggerRefresh(TestInfo testInfo) { when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); // Refresh Time, trigger refresh - when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))).thenReturn(clientOriginMock); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); ConfigurationSetting refreshKey = new ConfigurationSetting().setKey(KEY_FILTER).setLabel(EMPTY_LABEL) .setETag("new"); @@ -507,7 +516,8 @@ public void refreshStoresCheckFeatureFlagTestNoChange(TestInfo testInfo) { when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(false); FeatureFlagState newState = new FeatureFlagState( - List.of(new FeatureFlags(new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), + List.of(new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null)), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); // Config Store doesn't return a watch key change. @@ -533,7 +543,8 @@ public void refreshStoresCheckFeatureFlagTestTriggerRefresh(TestInfo testInfo) { setupFeatureFlagLoad(); when(clientOriginMock.checkWatchKeys(Mockito.any(), Mockito.any(Context.class))).thenReturn(true); - FeatureFlags featureFlags = new FeatureFlags(new SettingSelector(), watchKeysFeatureFlags); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(new SettingSelector(), + watchKeysFeatureFlags); FeatureFlagState newState = new FeatureFlagState(List.of(featureFlags), Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); @@ -568,7 +579,8 @@ private void setupFeatureFlagLoad() { when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); when(connectionManagerMock.getFeatureFlagStore()).thenReturn(featureStore); when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); - when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))).thenReturn(clientOriginMock); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); } private List generateWatchKeys() { @@ -589,4 +601,148 @@ private List generateFeatureFlagWatchKeys() { watchKeys.add(currentWatchKey); return watchKeys; } + + @Test + public void refreshAllWithWatchedConfigurationSettingsTest(TestInfo testInfo) { + // Test that when refreshAll is enabled, watched configuration settings are used instead of watch keys + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + // Set up watched configuration settings state + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + // Config Store returns a change via watched configuration settings + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(true); + + try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { + stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); + stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(state); + stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock); + + RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck( + clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + + assertTrue(eventData.getDoRefresh()); + verify(clientFactoryMock, times(1)).setCurrentConfigStoreClient(Mockito.eq(endpoint), Mockito.eq(endpoint)); + // Verify checkWatchKeys is called (watched configuration settings path) + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); + // Verify getWatchKey is NOT called (traditional watch key path) + verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(), + Mockito.any(Context.class)); + } + } + + @Test + public void refreshAllWithNullWatchKeysTest(TestInfo testInfo) { + // Test that when refreshAll is enabled with null watchKeys, watched configuration settings are still used + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + FeatureFlagStore disabledFeatureStore = new FeatureFlagStore(); + disabledFeatureStore.setEnabled(false); + when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + // Set up state with null watch keys but valid watched configuration settings + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), null); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); + + try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { + stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); + stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(state); + stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock); + + RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck( + clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + + // No change detected, so should not refresh + assertFalse(eventData.getDoRefresh()); + verify(clientOriginMock, times(1)).checkWatchKeys(Mockito.any(SettingSelector.class), + Mockito.any(Context.class)); + } + } + + @Test + public void watchedConfigurationSettingsNoChangeTest(TestInfo testInfo) { + // Test that watched configuration settings correctly detect no change + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + FeatureFlagStore disabledFeatureStore = new FeatureFlagStore(); + disabledFeatureStore.setEnabled(false); + when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + // Return false indicating no changes detected + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(false); + + try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { + stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); + stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(state); + stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock); + + RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck( + clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + + assertFalse(eventData.getDoRefresh()); + verify(currentStateMock, times(1)).updateStateRefresh(Mockito.any(), Mockito.any()); + } + } + + @Test + public void watchedConfigurationSettingsWithChangeDetectedTest(TestInfo testInfo) { + // Test that watched configuration settings correctly detect changes + endpoint = testInfo.getDisplayName() + ".azconfig.io"; + + when(connectionManagerMock.getMonitoring()).thenReturn(monitoring); + when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); + when(clientFactoryMock.getNextActiveClient(Mockito.eq(endpoint), Mockito.booleanThat(value -> true))) + .thenReturn(clientOriginMock); + + WatchedConfigurationSettings watchedConfigurationSettings = new WatchedConfigurationSettings( + new SettingSelector().setKeyFilter(KEY_FILTER).setLabelFilter(EMPTY_LABEL), + generateWatchKeys()); + State state = new State(null, List.of(watchedConfigurationSettings), + Math.toIntExact(Duration.ofMinutes(-1).getSeconds()), endpoint); + + // Return true indicating changes detected + when(clientOriginMock.checkWatchKeys(Mockito.any(SettingSelector.class), Mockito.any(Context.class))) + .thenReturn(true); + + try (MockedStatic stateHolderMock = Mockito.mockStatic(StateHolder.class)) { + stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(true); + stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(state); + stateHolderMock.when(StateHolder::getCurrentState).thenReturn(currentStateMock); + + RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck( + clientFactoryMock, Duration.ofMinutes(10), (long) 60, replicaLookUpMock); + + assertTrue(eventData.getDoRefresh()); + } + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java index b6b48e7288e6..3e18eb2263ec 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientTest.java @@ -45,6 +45,7 @@ import com.azure.data.appconfiguration.models.SettingSelector; import com.azure.data.appconfiguration.models.SnapshotComposition; import com.azure.identity.CredentialUnavailableException; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import reactor.core.publisher.Mono; @@ -70,7 +71,7 @@ public class AppConfigurationReplicaClientTest { @Mock private Response snapshotResponseMock; - + @Mock private Context contextMock; @@ -141,13 +142,16 @@ public void listSettingsTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); when(responseMock.getStatusCode()).thenReturn(429); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(408); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(500); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); when(responseMock.getStatusCode()).thenReturn(499); assertThrows(HttpResponseException.class, () -> client.listSettings(new SettingSelector(), contextMock)); @@ -170,7 +174,8 @@ public void listFeatureFlagsTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenReturn(new PagedIterable<>(pagedFlux)); - assertEquals(configurations, client.listFeatureFlags(new SettingSelector(), contextMock).getFeatureFlags()); + assertEquals(configurations, + client.listFeatureFlags(new SettingSelector(), contextMock).getConfigurationSettings()); when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); @@ -196,7 +201,8 @@ public void listSettingsUnknownHostTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenThrow(new UncheckedIOException(new UnknownHostException())); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); } @Test @@ -206,15 +212,17 @@ public void listSettingsNoCredentialTest() { when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) .thenThrow(new CredentialUnavailableException("No Credential")); - assertThrows(CredentialUnavailableException.class, () -> client.listSettings(new SettingSelector(), contextMock)); + assertThrows(CredentialUnavailableException.class, + () -> client.listSettings(new SettingSelector(), contextMock)); } @Test public void getWatchNoCredentialTest() { AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); - when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), Mockito.any())) - .thenThrow(new CredentialUnavailableException("No Credential")); + when(clientMock.getConfigurationSettingWithResponse(Mockito.any(), Mockito.any(), Mockito.anyBoolean(), + Mockito.any())) + .thenThrow(new CredentialUnavailableException("No Credential")); assertThrows(CredentialUnavailableException.class, () -> client.getWatchKey("key", "label", contextMock)); } @@ -265,20 +273,24 @@ public void listSettingSnapshotTest() { when(clientMock.listConfigurationSettingsForSnapshot(Mockito.any())).thenThrow(exceptionMock); when(exceptionMock.getResponse()).thenReturn(responseMock); when(responseMock.getStatusCode()).thenReturn(429); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(408); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(500); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(responseMock.getStatusCode()).thenReturn(499); assertThrows(HttpResponseException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); when(clientMock.getSnapshotWithResponse(Mockito.any(), Mockito.any(), Mockito.any())) .thenThrow(new UncheckedIOException(new UnknownHostException())); - assertThrows(AppConfigurationStatusException.class, () -> client.listSettingSnapshot("SnapshotName", contextMock)); + assertThrows(AppConfigurationStatusException.class, + () -> client.listSettingSnapshot("SnapshotName", contextMock)); } @Test @@ -340,7 +352,8 @@ public void checkWatchKeysTest() { when(supplierMock.get()).thenReturn(Mono.just(pagedResponse)); - when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenReturn(new PagedIterable<>(pagedFlux)); + when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) + .thenReturn(new PagedIterable<>(pagedFlux)); assertFalse(client.checkWatchKeys(new SettingSelector(), contextMock)); pagedResponse.close(); @@ -349,4 +362,67 @@ public void checkWatchKeysTest() { } } + @Test + public void watchedConfigurationSettingsTest() { + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); + + ConfigurationSetting setting1 = new ConfigurationSetting().setKey("key1").setLabel("label1"); + ConfigurationSetting setting2 = new ConfigurationSetting().setKey("key2").setLabel("label2"); + List configurations = List.of(setting1, setting2); + + PagedFlux pagedFlux = new PagedFlux<>(supplierMock); + HttpHeaders headers = new HttpHeaders().add(HttpHeaderName.ETAG, "test-etag-value"); + PagedResponse pagedResponse = new PagedResponseBase( + null, 200, headers, configurations, null, null); + + when(supplierMock.get()).thenReturn(Mono.just(pagedResponse)); + when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) + .thenReturn(new PagedIterable<>(pagedFlux)); + + SettingSelector selector = new SettingSelector().setKeyFilter("*"); + WatchedConfigurationSettings result = client.loadWatchedSettings(selector, contextMock); + + assertEquals(2, result.getConfigurationSettings().size()); + assertEquals("key1", result.getConfigurationSettings().get(0).getKey()); + assertEquals("key2", result.getConfigurationSettings().get(1).getKey()); + assertEquals(1, result.getSettingSelector().getMatchConditions().size()); + assertEquals("test-etag-value", result.getSettingSelector().getMatchConditions().get(0).getIfNoneMatch()); + assertEquals(0, client.getFailedAttempts()); + } + + @Test + public void watchedConfigurationSettingsErrorTest() { + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); + + when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())).thenThrow(exceptionMock); + when(exceptionMock.getResponse()).thenReturn(responseMock); + when(responseMock.getStatusCode()).thenReturn(429); + + assertThrows(AppConfigurationStatusException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(408); + assertThrows(AppConfigurationStatusException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(500); + assertThrows(AppConfigurationStatusException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + + when(responseMock.getStatusCode()).thenReturn(499); + assertThrows(HttpResponseException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + } + + @Test + public void watchedConfigurationSettingsUncheckedIOExceptionTest() { + AppConfigurationReplicaClient client = new AppConfigurationReplicaClient(endpoint, endpoint, clientMock); + + when(clientMock.listConfigurationSettings(Mockito.any(), Mockito.any())) + .thenThrow(new UncheckedIOException(new IOException("Network error"))); + + assertThrows(AppConfigurationStatusException.class, + () -> client.loadWatchedSettings(new SettingSelector(), contextMock)); + } + } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java new file mode 100644 index 000000000000..e12c9b57d7db --- /dev/null +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.appconfiguration.config.implementation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.MockitoSession; +import org.mockito.quality.Strictness; +import org.springframework.boot.context.config.Profiles; + +import com.azure.core.util.Context; +import com.azure.data.appconfiguration.models.SettingSelector; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreTrigger; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; +import com.azure.spring.cloud.appconfiguration.config.implementation.properties.FeatureFlagStore; + +public class AzureAppConfigDataLoaderTest { + + @Mock + private AppConfigurationReplicaClient clientMock; + + @Mock + private WatchedConfigurationSettings watchedConfigurationSettingsMock; + + private AzureAppConfigDataResource resource; + + private ConfigStore configStore; + + private MockitoSession session; + + private static final String ENDPOINT = "https://test.azconfig.io"; + + private static final String KEY_FILTER = "/application/*"; + + private static final String LABEL_FILTER = "prod"; + + @BeforeEach + public void setup() { + session = Mockito.mockitoSession().initMocks(this).strictness(Strictness.STRICT_STUBS).startMocking(); + MockitoAnnotations.openMocks(this); + + configStore = new ConfigStore(); + configStore.setEndpoint(ENDPOINT); + configStore.setEnabled(true); + + // Setup feature flags + FeatureFlagStore featureFlagStore = new FeatureFlagStore(); + featureFlagStore.setEnabled(false); + configStore.setFeatureFlags(featureFlagStore); + + // Setup basic resource + Profiles profiles = Mockito.mock(Profiles.class); + lenient().when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER)); + + resource = new AzureAppConfigDataResource(true, configStore, profiles, false, Duration.ofMinutes(1)); + } + + @AfterEach + public void cleanup() throws Exception { + MockitoAnnotations.openMocks(this).close(); + session.finishMocking(); + } + + @Test + public void createWatchedConfigurationSettingsWithSingleSelectorTest() throws Exception { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mocks + when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) + .thenReturn(watchedConfigurationSettingsMock); + // Use reflection to test the private method + AzureAppConfigDataLoader loader = createLoader(); + List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + + // Verify + assertNotNull(result); + assertEquals(1, result.size()); + + ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); + verify(clientMock, times(1)).loadWatchedSettings(selectorCaptor.capture(), any(Context.class)); + + SettingSelector capturedSelector = selectorCaptor.getValue(); + assertEquals(KEY_FILTER + "*", capturedSelector.getKeyFilter()); + assertEquals(LABEL_FILTER, capturedSelector.getLabelFilter()); + } + + @Test + public void createWatchedConfigurationSettingsWithMultipleSelectorsTest() throws Exception { + // Setup multiple selectors + AppConfigurationKeyValueSelector selector1 = new AppConfigurationKeyValueSelector(); + selector1.setKeyFilter("/app1/*"); + selector1.setLabelFilter("dev"); + configStore.getSelects().add(selector1); + + AppConfigurationKeyValueSelector selector2 = new AppConfigurationKeyValueSelector(); + selector2.setKeyFilter("/app2/*"); + selector2.setLabelFilter("prod"); + configStore.getSelects().add(selector2); + + // Setup mocks + when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) + .thenReturn(watchedConfigurationSettingsMock); + + // Test + AzureAppConfigDataLoader loader = createLoader(); + List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + + // Verify - should create watched configuration settings for both selectors + assertNotNull(result); + assertEquals(2, result.size()); + verify(clientMock, times(2)).loadWatchedSettings(any(SettingSelector.class), any(Context.class)); + } + + @Test + public void createWatchedConfigurationSettingsSkipsSnapshotsTest() throws Exception { + // Setup selector with snapshot + AppConfigurationKeyValueSelector snapshotSelector = new AppConfigurationKeyValueSelector(); + snapshotSelector.setSnapshotName("my-snapshot"); + configStore.getSelects().add(snapshotSelector); + + // Setup regular selector + AppConfigurationKeyValueSelector regularSelector = new AppConfigurationKeyValueSelector(); + regularSelector.setKeyFilter(KEY_FILTER); + regularSelector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(regularSelector); + + // Setup mocks + when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) + .thenReturn(watchedConfigurationSettingsMock); + + // Test + AzureAppConfigDataLoader loader = createLoader(); + List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + + // Verify - snapshot should be skipped, only regular selector should be processed + assertNotNull(result); + assertEquals(1, result.size()); + verify(clientMock, times(1)).loadWatchedSettings(any(SettingSelector.class), any(Context.class)); + } + + @Test + public void createWatchedConfigurationSettingsWithMultipleLabelsTest() throws Exception { + // Setup selector with multiple labels + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter("dev,prod,test"); + configStore.getSelects().add(selector); + + // Setup mocks + when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) + .thenReturn(watchedConfigurationSettingsMock); + // Test + AzureAppConfigDataLoader loader = createLoader(); + List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + + // Verify - should create watched configuration settings for each label + assertNotNull(result); + assertEquals(3, result.size()); + verify(clientMock, times(3)).loadWatchedSettings(any(SettingSelector.class), any(Context.class)); + } + + @Test + public void refreshAllEnabledUsesWatchedConfigurationSettingsTest() throws Exception { + // Setup monitoring with refreshAll enabled + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(true); + configStore.setMonitoring(monitoring); + + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mocks + when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) + .thenReturn(watchedConfigurationSettingsMock); + + // Test - verify that watched configuration settings are created when refreshAll is enabled + AzureAppConfigDataLoader loader = createLoader(); + List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + + // Verify watched configuration settings were created + assertNotNull(result); + assertEquals(1, result.size()); + verify(clientMock, times(1)).loadWatchedSettings(any(SettingSelector.class), any(Context.class)); + } + + @Test + public void refreshAllDisabledUsesWatchKeysTest() throws Exception { + // Setup monitoring with refreshAll disabled (traditional watch keys) + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(true); + + // Add trigger for traditional watch key + AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); + trigger.setKey("sentinel"); + trigger.setLabel("prod"); + monitoring.setTriggers(List.of(trigger)); + + configStore.setMonitoring(monitoring); + + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Verify that when refreshAll is false, triggers are configured + // The actual validation happens in validateAndInit which is called during load + assertEquals(1, monitoring.getTriggers().size()); + assertEquals("sentinel", monitoring.getTriggers().get(0).getKey()); + } + + // Helper methods + + private AzureAppConfigDataLoader createLoader() { + org.springframework.boot.logging.DeferredLogFactory logFactory = Mockito + .mock(org.springframework.boot.logging.DeferredLogFactory.class); + when(logFactory.getLog(any(Class.class))).thenReturn(new org.springframework.boot.logging.DeferredLog()); + return new AzureAppConfigDataLoader(logFactory); + } + + private List invokeGetWatchedConfigurationSettings( + AzureAppConfigDataLoader loader, AppConfigurationReplicaClient client) throws Exception { + // Set resource field in the loader using reflection + java.lang.reflect.Field resourceField = AzureAppConfigDataLoader.class.getDeclaredField("resource"); + resourceField.setAccessible(true); + resourceField.set(loader, resource); + + // Set requestContext field (it can be null for this test) + java.lang.reflect.Field requestContextField = AzureAppConfigDataLoader.class.getDeclaredField("requestContext"); + requestContextField.setAccessible(true); + requestContextField.set(loader, Context.NONE); + + // Use reflection to invoke private method + java.lang.reflect.Method method = AzureAppConfigDataLoader.class + .getDeclaredMethod("getWatchedConfigurationSettings", AppConfigurationReplicaClient.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + List result = (List) method.invoke(loader, client); + return result; + } +} diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java index f2ad1eca8024..91c44e0b1e7c 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/FeatureFlagClientTest.java @@ -36,7 +36,7 @@ import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagConfigurationSetting; import com.azure.data.appconfiguration.models.FeatureFlagFilter; -import com.azure.spring.cloud.appconfiguration.config.implementation.feature.FeatureFlags; +import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Allocation; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Feature; import com.azure.spring.cloud.appconfiguration.config.implementation.feature.entity.Variant; @@ -51,15 +51,15 @@ public class FeatureFlagClientTest { private FeatureFlagClient featureFlagClient; - private String[] emptyLabelList = {"\0"}; + private String[] emptyLabelList = { "\0" }; private static final FeatureFlagConfigurationSetting TELEMETRY_FEATURE = createItemFeatureFlag( - ".appconfig.featureflag/", "Delta", - FEATURE_VALUE_TELEMETRY, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "Delta", + FEATURE_VALUE_TELEMETRY, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); private static final FeatureFlagConfigurationSetting ALL_FEATURE = createItemFeatureFlag( - ".appconfig.featureflag/", "Delta", - FEATURE_VALUE_ALL, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "Delta", + FEATURE_VALUE_ALL, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); private MockitoSession session; @@ -80,58 +80,65 @@ public void cleanup() throws Exception { @Test public void loadFeatureFlagsTestNoFeatureFlags() { List settings = List.of(new ConfigurationSetting().setKey("FakeKey")); - FeatureFlags featureFlags = new FeatureFlags(null, settings); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - contextMock); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, + emptyLabelList, + contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals("FakeKey", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); + assertEquals("FakeKey", featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); assertEquals(0, featureFlagClient.getFeatureFlags().size()); } @Test public void loadFeatureFlagsTestFeatureFlags() { List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false), - new FeatureFlagConfigurationSetting("Beta", true)); - FeatureFlags featureFlags = new FeatureFlags(null, settings); + new FeatureFlagConfigurationSetting("Beta", true)); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - contextMock); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, + emptyLabelList, + contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); - assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); + assertEquals(".appconfig.featureflag/Alpha", + featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); + assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getConfigurationSettings().get(1).getKey()); assertEquals(2, featureFlagClient.getFeatureFlags().size()); } @Test public void loadFeatureFlagsTestMultipleLoads() { List settings = List.of(new FeatureFlagConfigurationSetting("Alpha", false), - new FeatureFlagConfigurationSetting("Beta", true)); - FeatureFlags featureFlags = new FeatureFlags(null, settings); + new FeatureFlagConfigurationSetting("Beta", true)); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - contextMock); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, + emptyLabelList, + contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); - assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); + assertEquals(".appconfig.featureflag/Alpha", + featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); + assertEquals(".appconfig.featureflag/Beta", featureFlagsList.get(0).getConfigurationSettings().get(1).getKey()); assertEquals(2, featureFlagClient.getFeatureFlags().size()); List settings2 = List.of(new FeatureFlagConfigurationSetting("Alpha", true), - new FeatureFlagConfigurationSetting("Gamma", false)); - featureFlags = new FeatureFlags(null, settings2); + new FeatureFlagConfigurationSetting("Gamma", false)); + featureFlags = new WatchedConfigurationSettings(null, settings2); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals(".appconfig.featureflag/Alpha", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); - assertEquals(".appconfig.featureflag/Gamma", featureFlagsList.get(0).getFeatureFlags().get(1).getKey()); + assertEquals(".appconfig.featureflag/Alpha", + featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); + assertEquals(".appconfig.featureflag/Gamma", + featureFlagsList.get(0).getConfigurationSettings().get(1).getKey()); assertEquals(3, featureFlagClient.getFeatureFlags().size()); List features = featureFlagClient.getFeatureFlags(); assertTrue(features.get(0).isEnabled()); @@ -170,14 +177,16 @@ public void loadFeatureFlagsTestTargetingFilter() { targetingFilter.addParameter("Audience", parameters); targetingFlag.addClientFilter(targetingFilter); List settings = List.of(targetingFlag); - FeatureFlags featureFlags = new FeatureFlags(null, settings); + WatchedConfigurationSettings featureFlags = new WatchedConfigurationSettings(null, settings); when(clientMock.listFeatureFlags(Mockito.any(), Mockito.any(Context.class))).thenReturn(featureFlags); - List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, emptyLabelList, - contextMock); + List featureFlagsList = featureFlagClient.loadFeatureFlags(clientMock, null, + emptyLabelList, + contextMock); assertEquals(1, featureFlagsList.size()); assertEquals(featureFlags, featureFlagsList.get(0)); - assertEquals(".appconfig.featureflag/TargetingTest", featureFlagsList.get(0).getFeatureFlags().get(0).getKey()); + assertEquals(".appconfig.featureflag/TargetingTest", + featureFlagsList.get(0).getConfigurationSettings().get(0).getKey()); assertEquals(1, featureFlagClient.getFeatureFlags().size()); } @@ -209,9 +218,9 @@ public void testAllocationIdInTelemetry() { @Test public void testAllocationIdWithDifferentSeed() { FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", - "{\"allocation\":{\"seed\":\"newSeed\"},\"telemetry\":{\"enabled\":true}}", FEATURE_LABEL, - FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", + "{\"allocation\":{\"seed\":\"newSeed\"},\"telemetry\":{\"enabled\":true}}", FEATURE_LABEL, + FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); assertEquals("RkxUK5CoaOaNWBjc55Mi", feature.getTelemetry().getMetadata().get("AllocationId")); @@ -221,7 +230,7 @@ public void testAllocationIdWithDifferentSeed() { public void testAllocationIdWithVariants() { String flagValue = "{\"allocation\": { \"percentile\": [{\"variant\": \"Off\", \"from\": 0, \"to\": 50}, {\"variant\": \"On\", \"from\": 50, \"to\": 100}], \"default_when_enabled\": \"Off2\", \"default_when_disabled\": \"Off\" }, \"telemetry\": {\"enabled\": true}}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); assertEquals("wGzzPy4qGy92SHnMtSvY", feature.getTelemetry().getMetadata().get("AllocationId")); @@ -230,9 +239,9 @@ public void testAllocationIdWithVariants() { @Test public void testAllocationIdWithEmptyAllocation() { FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", - "{\"allocation\":{},\"telemetry\":{\"enabled\":true}}}", FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, - TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", + "{\"allocation\":{},\"telemetry\":{\"enabled\":true}}}", FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, + TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); assertNull(feature.getTelemetry().getMetadata().get("AllocationId")); @@ -241,12 +250,12 @@ public void testAllocationIdWithEmptyAllocation() { @Test public void testVariantsParsing() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true," - + "\"variants\":[" - + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\",\"status_override\":\"Enabled\"}," - + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\",\"status_override\":\"None\"}" - + "]}"; + + "\"variants\":[" + + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\",\"status_override\":\"Enabled\"}," + + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\",\"status_override\":\"None\"}" + + "]}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -267,16 +276,16 @@ public void testVariantsParsing() { @Test public void testAllocationParsing() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true," - + "\"allocation\":{" - + "\"default_when_enabled\":\"Red\"," - + "\"default_when_disabled\":\"Off\"," - + "\"seed\":\"testSeed\"," - + "\"user\":[{\"variant\":\"Green\",\"users\":[\"user1\",\"user2\"]}]," - + "\"group\":[{\"variant\":\"Blue\",\"groups\":[\"group1\"]}]," - + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]" - + "}}"; + + "\"allocation\":{" + + "\"default_when_enabled\":\"Red\"," + + "\"default_when_disabled\":\"Off\"," + + "\"seed\":\"testSeed\"," + + "\"user\":[{\"variant\":\"Green\",\"users\":[\"user1\",\"user2\"]}]," + + "\"group\":[{\"variant\":\"Blue\",\"groups\":[\"group1\"]}]," + + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]" + + "}}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -309,16 +318,16 @@ public void testAllocationParsing() { @Test public void testVariantsAndAllocationTogether() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true," - + "\"variants\":[" - + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\"}," - + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\"}" - + "]," - + "\"allocation\":{" - + "\"default_when_enabled\":\"Red\"," - + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]" - + "}}"; + + "\"variants\":[" + + "{\"name\":\"Red\",\"configuration_value\":\"#FF0000\"}," + + "{\"name\":\"Green\",\"configuration_value\":\"#00FF00\"}" + + "]," + + "\"allocation\":{" + + "\"default_when_enabled\":\"Red\"," + + "\"percentile\":[{\"variant\":\"Red\",\"from\":0,\"to\":50},{\"variant\":\"Green\",\"from\":50,\"to\":100}]" + + "}}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -336,14 +345,14 @@ public void testVariantsAndAllocationTogether() { @Test public void testVariantsWithComplexConfigurationValue() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true," - + "\"variants\":[" - + "{\"name\":\"SimpleString\",\"configuration_value\":\"hello\"}," - + "{\"name\":\"Number\",\"configuration_value\":42}," - + "{\"name\":\"Boolean\",\"configuration_value\":true}," - + "{\"name\":\"Object\",\"configuration_value\":{\"key\":\"value\",\"nested\":{\"prop\":123}}}" - + "]}"; + + "\"variants\":[" + + "{\"name\":\"SimpleString\",\"configuration_value\":\"hello\"}," + + "{\"name\":\"Number\",\"configuration_value\":42}," + + "{\"name\":\"Boolean\",\"configuration_value\":true}," + + "{\"name\":\"Object\",\"configuration_value\":{\"key\":\"value\",\"nested\":{\"prop\":123}}}" + + "]}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); @@ -361,7 +370,7 @@ public void testVariantsWithComplexConfigurationValue() { public void testFeatureFlagWithoutVariantsOrAllocation() { String flagValue = "{\"id\":\"TestFeature\",\"enabled\":true}"; FeatureFlagConfigurationSetting featureFlag = createItemFeatureFlag( - ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); + ".appconfig.featureflag/", "TestFeature", flagValue, FEATURE_LABEL, FEATURE_FLAG_CONTENT_TYPE, TEST_E_TAG); Feature feature = FeatureFlagClient.createFeature(featureFlag, TEST_ENDPOINT); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java index ab4f2e6c0656..27ff6e4cdb88 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationStoreMonitoringTest.java @@ -2,13 +2,12 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation.properties; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - import java.time.Duration; import java.util.ArrayList; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; public class AppConfigurationStoreMonitoringTest { @@ -19,15 +18,9 @@ public void validateAndInitTest() { AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); monitoring.validateAndInit(); - // Enabled throw error if no triggers - monitoring.setEnabled(true); - assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit()); - List triggers = new ArrayList<>(); monitoring.setTriggers(triggers); - assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit()); - AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); trigger.setKey("sentinal"); @@ -51,4 +44,45 @@ public void validateAndInitTest() { monitoring.validateAndInit(); } + @Test + public void refreshAllEnabledWithoutTriggersTest() { + // When refreshAll is enabled, triggers are not required + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(true); + + // Should not throw an exception even with no triggers + monitoring.validateAndInit(); + + // Verify refresh interval validation still applies + monitoring.setRefreshInterval(Duration.ofSeconds(0)); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> monitoring.validateAndInit()); + assertEquals("Minimum refresh interval time is 1 Second.", e.getMessage()); + } + + @Test + public void refreshAllWithTriggersTest() { + // Even when refreshAll is enabled, having triggers should still be valid + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(true); + + List triggers = new ArrayList<>(); + AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); + trigger.setKey("sentinel"); + triggers.add(trigger); + monitoring.setTriggers(triggers); + + // Should not throw an exception + monitoring.validateAndInit(); + } + + @Test + public void monitoringDisabledWithRefreshAllTest() { + // When monitoring is disabled, refreshAll setting should not matter + AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); + monitoring.setEnabled(false); + + // Should not throw an exception even with no triggers + monitoring.validateAndInit(); + } + }