diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md index bbf781cd92c5..6383c6e04f18 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Added `startup-timeout` configuration option that enables automatic retry with backoff when transient failures occur during application startup. The provider will continue retrying until the timeout expires (default: 100 seconds). + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java index 8bd7792707e9..b42aaa5c2657 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationReplicaClientFactory.java @@ -107,6 +107,16 @@ String findOriginForEndpoint(String endpoint) { return endpoint; } + /** + * Gets the duration in milliseconds until the next client becomes available for the specified store. + * + * @param originEndpoint the origin configuration store endpoint + * @return duration in milliseconds until next client is available, or 0 if one is available now + */ + long getMillisUntilNextClientAvailable(String originEndpoint) { + return CONNECTIONS.get(originEndpoint).getMillisUntilNextClientAvailable(); + } + /** * Sets the current active replica for a configuration store. * 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 bca6a57646a9..6e4afd3b726b 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 @@ -2,8 +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.PUSH_REFRESH; - import java.io.IOException; import java.time.Instant; import java.util.ArrayList; @@ -11,8 +9,8 @@ import java.util.List; import org.apache.commons.logging.Log; -import org.springframework.boot.context.config.ConfigData; import org.springframework.boot.bootstrap.BootstrapRegistry.InstanceSupplier; +import org.springframework.boot.context.config.ConfigData; import org.springframework.boot.context.config.ConfigDataLoader; import org.springframework.boot.context.config.ConfigDataLoaderContext; import org.springframework.boot.context.config.ConfigDataResourceNotFoundException; @@ -24,6 +22,7 @@ import com.azure.core.util.Context; import com.azure.data.appconfiguration.models.ConfigurationSetting; import com.azure.data.appconfiguration.models.SettingSelector; +import static com.azure.spring.cloud.appconfiguration.config.implementation.AppConfigurationConstants.PUSH_REFRESH; 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; @@ -61,7 +60,7 @@ public class AzureAppConfigDataLoader implements ConfigDataLoader featureFlagClient)); } - // Reset telemetry usage for refresh featureFlagClient.resetTelemetry(); + List> sourceList = new ArrayList<>(); if (resource.isConfigStoreEnabled()) { - replicaClientFactory = context.getBootstrapContext() - .get(AppConfigurationReplicaClientFactory.class); - keyVaultClientFactory = context.getBootstrapContext() - .get(AppConfigurationKeyVaultClientFactory.class); - - boolean reloadFailed = false; - boolean pushRefresh = false; - Exception lastException = null; - PushNotification notification = resource.getMonitoring().getPushNotification(); - if ((notification.getPrimaryToken() != null - && StringUtils.hasText(notification.getPrimaryToken().getName())) - || (notification.getSecondaryToken() != null - && StringUtils.hasText(notification.getSecondaryToken().getName()))) { - pushRefresh = true; + replicaClientFactory = context.getBootstrapContext().get(AppConfigurationReplicaClientFactory.class); + keyVaultClientFactory = context.getBootstrapContext().get(AppConfigurationKeyVaultClientFactory.class); + + Exception loadException = loadConfiguration(sourceList); + if (loadException != null) { + if (resource.isRefresh()) { + logger.warn("Azure App Configuration failed during refresh for store: " + + resource.getEndpoint() + ". Continuing with existing configuration."); + } else { + logger.error("Azure App Configuration failed to load configuration during startup for store: " + + resource.getEndpoint() + ". Application cannot start without required configuration."); + failedToGeneratePropertySource(loadException); + } } - // Feature Management needs to be set in the last config store. - requestContext = new Context("refresh", resource.isRefresh()).addData(PUSH_REFRESH, pushRefresh); + } + + StateHolder.updateState(storeState); + if (!featureFlagClient.getFeatureFlags().isEmpty()) { + sourceList.add(new AppConfigurationFeatureManagementPropertySource(featureFlagClient)); + } + return new ConfigData(sourceList); + } + /** + * Loads configuration from Azure App Configuration with replica failover support. + * + * @param sourceList the list to populate with property sources + * @return the exception if loading failed, null on success + */ + private Exception loadConfiguration(List> sourceList) { + PushNotification notification = resource.getMonitoring().getPushNotification(); + boolean pushRefresh = (notification.getPrimaryToken() != null + && StringUtils.hasText(notification.getPrimaryToken().getName())) + || (notification.getSecondaryToken() != null + && StringUtils.hasText(notification.getSecondaryToken().getName())); + + // During refresh, only attempt once since failures are non-fatal + if (resource.isRefresh()) { + requestContext = new Context("refresh", true).addData(PUSH_REFRESH, pushRefresh); replicaClientFactory.findActiveClients(resource.getEndpoint()); + return attemptLoadFromClients(sourceList); + } - AppConfigurationReplicaClient client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), - true); + requestContext = new Context("refresh", false).addData(PUSH_REFRESH, pushRefresh); - while (client != null) { - final AppConfigurationReplicaClient currentClient = client; + // During startup, retry with backoff until deadline + Instant startTime = Instant.now(); + Instant deadline = startTime.plusSeconds(resource.getStartupTimeout().getSeconds()); + Exception lastException = null; + int postFixedWindowAttempts = 0; - if (reloadFailed - && !AppConfigurationRefreshUtil.refreshStoreCheck(currentClient, - replicaClientFactory.findOriginForEndpoint(currentClient.getEndpoint()), requestContext)) { - // This store doesn't have any changes where to refresh store did. Skipping to next client. - client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), false); - continue; - } + while (Instant.now().isBefore(deadline)) { + // Ensure we do not retain partial results from previous failed attempts + sourceList.clear(); + replicaClientFactory.findActiveClients(resource.getEndpoint()); + lastException = attemptLoadFromClients(sourceList); + + if (lastException == null) { + return null; // Success + } - // Reverse in order to add Profile specific properties earlier, and last profile comes first - try { - sourceList.addAll(createSettings(currentClient)); - List featureFlags = createFeatureFlags(currentClient); - - logger.debug("PropertySource context."); - AppConfigurationStoreMonitoring monitoring = resource.getMonitoring(); - - storeState.setStateFeatureFlag(resource.getEndpoint(), featureFlags, - monitoring.getFeatureFlagRefreshInterval()); - - if (monitoring.isEnabled()) { - // Check if refreshAll is enabled - if so, use watched configuration settings - if (monitoring.getTriggers().size() == 0) { - // Use watched configuration settings for refresh - List watchedConfigurationSettingsList = getWatchedConfigurationSettings( - currentClient); - storeState.setState(resource.getEndpoint(), Collections.emptyList(), - watchedConfigurationSettingsList, monitoring.getRefreshInterval()); - } else { - // Use traditional watch key monitoring - List watchKeysSettings = monitoring.getTriggers().stream() - .map(trigger -> currentClient.getWatchKey(trigger.getKey(), trigger.getLabel(), - requestContext)) - .toList(); - - storeState.setState(resource.getEndpoint(), watchKeysSettings, - monitoring.getRefreshInterval()); - } + // All clients failed, use fixed backoff based on elapsed time + if (Instant.now().isBefore(deadline)) { + long elapsedSeconds = Instant.now().getEpochSecond() - startTime.getEpochSecond(); + Long backoffSeconds = getBackoffDuration(elapsedSeconds); + + // If backoff is null, elapsed time exceeds fixed intervals - use exponential backoff + if (backoffSeconds == null) { + postFixedWindowAttempts++; + // Convert nanoseconds to seconds + backoffSeconds = BackoffTimeCalculator.calculateBackoff(postFixedWindowAttempts) / 1_000_000_000L; + } + + // Don't wait longer than remaining time until deadline + long remainingSeconds = deadline.getEpochSecond() - Instant.now().getEpochSecond(); + long waitSeconds = Math.min(backoffSeconds, remainingSeconds); + + if (waitSeconds > 0) { + logger.debug("All replicas in backoff for store: " + resource.getEndpoint() + + ". Waiting " + waitSeconds + "s before retry (elapsed: " + elapsedSeconds + "s)."); + try { + Thread.sleep(waitSeconds * 1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return lastException; } - storeState.setLoadState(resource.getEndpoint(), true); // Success - configuration loaded, exit loop - lastException = null; - // Break out of the loop since we have successfully loaded configuration - break; - } catch (AppConfigurationStatusException e) { - reloadFailed = true; - replicaClientFactory.backoffClient(resource.getEndpoint(), currentClient.getEndpoint()); - lastException = e; - // Log the specific replica failure with context - AppConfigurationReplicaClient nextClient = replicaClientFactory - .getNextActiveClient(resource.getEndpoint(), false); - logReplicaFailure(currentClient, "status exception", nextClient != null, e); - client = nextClient; - } catch (Exception e) { - // Store the exception to potentially use if all replicas fail - lastException = e; // Log the specific replica failure with context - replicaClientFactory.backoffClient(resource.getEndpoint(), currentClient.getEndpoint()); - AppConfigurationReplicaClient nextClient = replicaClientFactory - .getNextActiveClient(resource.getEndpoint(), false); - logReplicaFailure(currentClient, "exception", nextClient != null, e); - client = nextClient; } - } // Check if all replicas failed - if (lastException != null && !resource.isRefresh()) { - // During startup, if all replicas failed, fail the application - logger.error("Azure App Configuration failed to load configuration during startup for store: " - + resource.getEndpoint() + ". Application cannot start without required configuration."); - failedToGeneratePropertySource(lastException); - } else if (lastException != null && resource.isRefresh()) { - // During refresh, log warning but don't fail the application - logger.warn("Azure App Configuration failed during refresh for store: " - + resource.getEndpoint() + ". Continuing with existing configuration."); } } - StateHolder.updateState(storeState); - if (featureFlagClient.getFeatureFlags().size() > 0) { - // Don't add feature flags if there are none, otherwise the local file can't load them. - sourceList.add(new AppConfigurationFeatureManagementPropertySource(featureFlagClient)); + return lastException; + } + + /** + * Gets the backoff duration based on elapsed time since startup began. + * + * @param elapsedSeconds the number of seconds elapsed since startup began + * @return the backoff duration in seconds, or null if elapsed time exceeds all thresholds + */ + private Long getBackoffDuration(long elapsedSeconds) { + for (int[] interval : STARTUP_BACKOFF_INTERVALS) { + if (elapsedSeconds < interval[0]) { + return (long) interval[1]; + } } - return new ConfigData(sourceList); + // Return null when elapsed time exceeds all defined thresholds + return null; + } + + /** + * Attempts to load configuration from available clients. + * + * @param sourceList the list to populate with property sources + * @return the exception if all clients failed, null on success + */ + private Exception attemptLoadFromClients(List> sourceList) { + boolean reloadFailed = false; + Exception lastException = null; + AppConfigurationReplicaClient client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), true); + + while (client != null) { + final AppConfigurationReplicaClient currentClient = client; + + if (reloadFailed && !AppConfigurationRefreshUtil.refreshStoreCheck(currentClient, + replicaClientFactory.findOriginForEndpoint(currentClient.getEndpoint()), requestContext)) { + client = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), false); + continue; + } + + try { + sourceList.addAll(createSettings(currentClient)); + List featureFlags = createFeatureFlags(currentClient); + + AppConfigurationStoreMonitoring monitoring = resource.getMonitoring(); + + storeState.setStateFeatureFlag(resource.getEndpoint(), featureFlags, + monitoring.getFeatureFlagRefreshInterval()); + + if (monitoring.isEnabled()) { + setupMonitoringState(currentClient, monitoring); + } + + storeState.setLoadState(resource.getEndpoint(), true); + return null; // Success + } catch (AppConfigurationStatusException e) { + reloadFailed = true; + lastException = e; + client = handleReplicaFailure(currentClient, "status exception", e); + } catch (Exception e) { + lastException = e; + client = handleReplicaFailure(currentClient, "exception", e); + } + } + + return lastException; + } + + /** + * Sets up the monitoring state based on the configuration. + * + * @param client the replica client + * @param monitoring the monitoring configuration + * @throws Exception if setting up monitoring fails + */ + private void setupMonitoringState(AppConfigurationReplicaClient client, AppConfigurationStoreMonitoring monitoring) + throws Exception { + if (monitoring.getTriggers().isEmpty()) { + // Use watched configuration settings for refresh + List watchedConfigurationSettingsList = getWatchedConfigurationSettings( + client); + storeState.setState(resource.getEndpoint(), Collections.emptyList(), + watchedConfigurationSettingsList, monitoring.getRefreshInterval()); + return; + } + // Use traditional watch key monitoring + List watchKeysSettings = monitoring.getTriggers().stream() + .map(trigger -> client.getWatchKey(trigger.getKey(), trigger.getLabel(), requestContext)) + .toList(); + + storeState.setState(resource.getEndpoint(), watchKeysSettings, monitoring.getRefreshInterval()); + } + + /** + * Handles a replica failure by backing off the client and getting the next available replica. + * + * @param client the failed client + * @param exceptionType a description of the exception type + * @param exception the exception that occurred + * @return the next available client, or null if none available + */ + private AppConfigurationReplicaClient handleReplicaFailure(AppConfigurationReplicaClient client, + String exceptionType, Exception exception) { + replicaClientFactory.backoffClient(resource.getEndpoint(), client.getEndpoint()); + AppConfigurationReplicaClient nextClient = replicaClientFactory.getNextActiveClient(resource.getEndpoint(), + false); + + String scenario = resource.isRefresh() ? "refresh" : "startup"; + String nextAction = nextClient != null ? "Trying next replica." : "No more replicas available."; + logger.warn("Azure App Configuration replica " + client.getEndpoint() + + " failed during " + scenario + " with " + exceptionType + ". " + + nextAction + " Store: " + resource.getEndpoint(), exception); + + return nextClient; } /** @@ -251,7 +359,7 @@ private List createSettings(AppConfigurationRepl List profiles = resource.getProfiles().getActive(); for (AppConfigurationKeyValueSelector selectedKeys : selects) { - AppConfigurationPropertySource propertySource = null; + AppConfigurationPropertySource propertySource; if (StringUtils.hasText(selectedKeys.getSnapshotName())) { propertySource = new AppConfigurationSnapshotPropertySource( @@ -324,24 +432,6 @@ private List getWatchedConfigurationSettings(AppCo return watchedConfigurationSettingsList; } - /** - * Logs a replica failure with contextual information about the failure scenario and available replicas. - * - * @param client the replica client that failed - * @param exceptionType a brief description of the exception type (e.g., "status exception", "exception") - * @param hasMoreReplicas whether there are additional replicas available to try - * @param exception the exception that caused the failure - */ - private void logReplicaFailure(AppConfigurationReplicaClient client, String exceptionType, - boolean hasMoreReplicas, Exception exception) { - String scenario = resource.isRefresh() ? "refresh" : "startup"; - String nextAction = hasMoreReplicas ? "Trying next replica." : "No more replicas available."; - - logger.warn("Azure App Configuration replica " + client.getEndpoint() - + " failed during " + scenario + " with " + exceptionType + ". " - + nextAction + " Store: " + resource.getEndpoint(), exception); - } - /** * Introduces a delay before throwing exceptions during startup to prevent fast crash loops. */ diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java index b1e28b885df5..da582adbf5bf 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLocationResolver.java @@ -128,7 +128,7 @@ public List resolveProfileSpecific( for (ConfigStore store : properties.getStores()) { locations.add( - new AzureAppConfigDataResource(properties.isEnabled(), store, profiles, START_UP.get(), properties.getRefreshInterval())); + new AzureAppConfigDataResource(properties.isEnabled(), store, profiles, START_UP.get(), properties.getRefreshInterval(), properties.getStartupTimeout())); } START_UP.set(false); return locations; diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java index ab1557a2a38b..dd5c5f8de978 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResource.java @@ -47,16 +47,21 @@ public class AzureAppConfigDataResource extends ConfigDataResource { /** The interval at which configuration should be refreshed from the store. */ private final Duration refreshInterval; + /** The timeout duration for retry attempts during startup. */ + private final Duration startupTimeout; + /** * Constructs a new AzureAppConfigDataResource with the specified configuration store settings. * + * @param appConfigEnabled true if Azure App Configuration is globally enabled * @param configStore the configuration store settings containing endpoint, selectors, and other options * @param profiles the Spring Boot profiles for conditional configuration loading * @param startup true if this is a startup load operation, false if it is a refresh operation * @param refreshInterval the interval at which configuration should be refreshed + * @param startupTimeout the timeout duration for retry attempts during startup */ AzureAppConfigDataResource(boolean appConfigEnabled, ConfigStore configStore, Profiles profiles, boolean startup, - Duration refreshInterval) { + Duration refreshInterval, Duration startupTimeout) { this.configStoreEnabled = appConfigEnabled && configStore.isEnabled(); this.endpoint = configStore.getEndpoint(); this.selects = configStore.getSelects(); @@ -66,6 +71,7 @@ public class AzureAppConfigDataResource extends ConfigDataResource { this.profiles = profiles; this.isRefresh = !startup; this.refreshInterval = refreshInterval; + this.startupTimeout = startupTimeout; } /** @@ -148,4 +154,13 @@ public boolean isRefresh() { public Duration getRefreshInterval() { return refreshInterval; } + + /** + * Gets the timeout duration for retry attempts during startup. + * + * @return the startup timeout duration + */ + public Duration getStartupTimeout() { + return startupTimeout; + } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java index 051600d02241..8a0956182e20 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java @@ -242,6 +242,43 @@ void backoffClient(String endpoint) { autoFailoverClients.get(endpoint).updateBackoffEndTime(Instant.now().plusNanos(backoffTime)); } + /** + * Gets the duration in milliseconds until the next client becomes available (exits backoff). + * Returns 0 if a client is already available, or the minimum wait time if all clients are in backoff. + * + * @return duration in milliseconds until next client is available, or 0 if one is available now + */ + long getMillisUntilNextClientAvailable() { + Instant now = Instant.now(); + Instant earliestAvailable = Instant.MAX; + + // Check configured clients + if (clients != null) { + for (AppConfigurationReplicaClient client : clients) { + Instant backoffEnd = client.getBackoffEndTime(); + if (!backoffEnd.isAfter(now)) { + return 0; // Client available now + } + if (backoffEnd.isBefore(earliestAvailable)) { + earliestAvailable = backoffEnd; + } + } + } + + // Check auto-failover clients + for (AppConfigurationReplicaClient client : autoFailoverClients.values()) { + Instant backoffEnd = client.getBackoffEndTime(); + if (!backoffEnd.isAfter(now)) { + return 0; // Client available now + } + if (backoffEnd.isBefore(earliestAvailable)) { + earliestAvailable = backoffEnd; + } + } + + return earliestAvailable.toEpochMilli() - now.toEpochMilli(); + } + /** * Updates the synchronization token for the specified client endpoint. * diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java index 8518170fc57c..8d925d1b54d0 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/AppConfigurationProperties.java @@ -36,6 +36,11 @@ public class AppConfigurationProperties { private Duration refreshInterval; + /** + * The timeout duration for retry attempts during startup. + */ + private Duration startupTimeout = Duration.ofSeconds(100); + /** * @return the enabled */ @@ -78,6 +83,20 @@ public void setRefreshInterval(Duration refreshInterval) { this.refreshInterval = refreshInterval; } + /** + * @return the startupTimeout + */ + public Duration getStartupTimeout() { + return startupTimeout; + } + + /** + * @param startupTimeout the startupTimeout to set + */ + public void setStartupTimeout(Duration startupTimeout) { + this.startupTimeout = startupTimeout; + } + /** * Validates at least one store is configured for use, and that they are valid. * @throws IllegalArgumentException when duplicate endpoints are configured @@ -115,5 +134,11 @@ public void validateAndInit() { if (refreshInterval != null) { Assert.isTrue(refreshInterval.getSeconds() >= 1, "Minimum refresh interval time is 1 Second."); } + if (startupTimeout == null) { + throw new IllegalArgumentException("startupTimeout cannot be null."); + } + if (startupTimeout.getSeconds() < 30 || startupTimeout.getSeconds() > 600) { + throw new IllegalArgumentException("startupTimeout must be between 30 and 600 seconds."); + } } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java index 6f8a3fa0bf7c..c168702bf14a 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java @@ -264,29 +264,28 @@ public void validateAndInit() { } if (StringUtils.hasText(connectionString)) { - String endpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connectionString)); + String parsedEndpoint = AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connectionString); try { // new URI is used to validate the endpoint as a valid URI - new URI(endpoint); - this.endpoint = endpoint; + new URI(parsedEndpoint); + this.endpoint = parsedEndpoint; } catch (URISyntaxException e) { throw new IllegalStateException("Endpoint in connection string is not a valid URI.", e); } - } else if (connectionStrings.size() > 0) { + } else if (!connectionStrings.isEmpty()) { for (String connection : connectionStrings) { - - String endpoint = (AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connection)); + String parsedEndpoint = AppConfigurationReplicaClientsBuilder.getEndpointFromConnectionString(connection); try { // new URI is used to validate the endpoint as a valid URI - new URI(endpoint).toURL(); + new URI(parsedEndpoint).toURL(); if (!StringUtils.hasText(this.endpoint)) { - this.endpoint = endpoint; + this.endpoint = parsedEndpoint; } } catch (MalformedURLException | URISyntaxException | IllegalArgumentException e) { throw new IllegalStateException("Endpoint in connection string is not a valid URI.", e); } } - } else if (endpoints.size() > 0) { + } else if (!endpoints.isEmpty()) { endpoint = endpoints.get(0); } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java index e12c9b57d7db..fc78444eeeb6 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataLoaderTest.java @@ -4,29 +4,35 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.time.Duration; import java.util.List; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.MockitoSession; import org.mockito.quality.Strictness; +import org.springframework.boot.bootstrap.ConfigurableBootstrapContext; +import org.springframework.boot.context.config.ConfigData; +import org.springframework.boot.context.config.ConfigDataLoaderContext; import org.springframework.boot.context.config.Profiles; +import org.springframework.boot.logging.DeferredLog; +import org.springframework.boot.logging.DeferredLogFactory; -import com.azure.core.util.Context; -import com.azure.data.appconfiguration.models.SettingSelector; -import com.azure.spring.cloud.appconfiguration.config.implementation.configuration.WatchedConfigurationSettings; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationKeyValueSelector; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreTrigger; @@ -39,10 +45,24 @@ public class AzureAppConfigDataLoaderTest { private AppConfigurationReplicaClient clientMock; @Mock - private WatchedConfigurationSettings watchedConfigurationSettingsMock; + private AppConfigurationReplicaClientFactory replicaClientFactoryMock; + + @Mock + private AppConfigurationKeyVaultClientFactory keyVaultClientFactoryMock; + + @Mock + private ConfigDataLoaderContext configDataLoaderContextMock; + + @Mock + private ConfigurableBootstrapContext bootstrapContextMock; + + @Mock + private DeferredLogFactory logFactoryMock; private AzureAppConfigDataResource resource; + private AzureAppConfigDataResource refreshResource; + private ConfigStore configStore; private MockitoSession session; @@ -71,7 +91,19 @@ public void setup() { Profiles profiles = Mockito.mock(Profiles.class); lenient().when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER)); - resource = new AzureAppConfigDataResource(true, configStore, profiles, false, Duration.ofMinutes(1)); + // Startup resource (isRefresh = false) + resource = new AzureAppConfigDataResource(true, configStore, profiles, true, Duration.ofMinutes(1), Duration.ofSeconds(30)); + // Refresh resource (isRefresh = true) + refreshResource = new AzureAppConfigDataResource(true, configStore, profiles, false, Duration.ofMinutes(1), Duration.ofSeconds(30)); + + // Setup common mocks for ConfigDataLoaderContext + lenient().when(configDataLoaderContextMock.getBootstrapContext()).thenReturn(bootstrapContextMock); + lenient().when(bootstrapContextMock.isRegistered(FeatureFlagClient.class)).thenReturn(false); + lenient().when(bootstrapContextMock.get(AppConfigurationReplicaClientFactory.class)) + .thenReturn(replicaClientFactoryMock); + lenient().when(bootstrapContextMock.get(AppConfigurationKeyVaultClientFactory.class)) + .thenReturn(keyVaultClientFactoryMock); + lenient().when(logFactoryMock.getLog(any(Class.class))).thenReturn(new DeferredLog()); } @AfterEach @@ -81,187 +113,210 @@ public void cleanup() throws Exception { } @Test - public void createWatchedConfigurationSettingsWithSingleSelectorTest() throws Exception { + public void loadSucceedsWhenNoClientsAvailableTest() throws IOException { // Setup selector AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); selector.setKeyFilter(KEY_FILTER); selector.setLabelFilter(LABEL_FILTER); configStore.getSelects().add(selector); - // Setup mocks - when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) - .thenReturn(watchedConfigurationSettingsMock); - // Use reflection to test the private method - AzureAppConfigDataLoader loader = createLoader(); - List result = invokeGetWatchedConfigurationSettings(loader, clientMock); - - // Verify - assertNotNull(result); - assertEquals(1, result.size()); + // Setup mocks - no clients available + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null); - ArgumentCaptor selectorCaptor = ArgumentCaptor.forClass(SettingSelector.class); - verify(clientMock, times(1)).loadWatchedSettings(selectorCaptor.capture(), any(Context.class)); + // Test using public load() method + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, resource); - SettingSelector capturedSelector = selectorCaptor.getValue(); - assertEquals(KEY_FILTER + "*", capturedSelector.getKeyFilter()); - assertEquals(LABEL_FILTER, capturedSelector.getLabelFilter()); + // Verify - returns empty ConfigData when no clients available + assertNotNull(result); + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); + verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true)); } @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)); + public void refreshAllDisabledUsesWatchKeysTest() { + // 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 + assertEquals(1, monitoring.getTriggers().size()); + assertEquals("sentinel", monitoring.getTriggers().get(0).getKey()); } + // Startup Retry Tests + @Test - public void createWatchedConfigurationSettingsSkipsSnapshotsTest() throws Exception { - // Setup selector with snapshot - AppConfigurationKeyValueSelector snapshotSelector = new AppConfigurationKeyValueSelector(); - snapshotSelector.setSnapshotName("my-snapshot"); - configStore.getSelects().add(snapshotSelector); - - // Setup regular selector - AppConfigurationKeyValueSelector regularSelector = new AppConfigurationKeyValueSelector(); - regularSelector.setKeyFilter(KEY_FILTER); - regularSelector.setLabelFilter(LABEL_FILTER); - configStore.getSelects().add(regularSelector); - - // Setup mocks - when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) - .thenReturn(watchedConfigurationSettingsMock); - - // Test - AzureAppConfigDataLoader loader = createLoader(); - List result = invokeGetWatchedConfigurationSettings(loader, clientMock); - - // Verify - snapshot should be skipped, only regular selector should be processed + public void startupSucceedsOnFirstAttemptTest() throws IOException { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); + + // Setup mocks - no clients available (returns empty result) + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null); + + // Test using public load() method + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, resource); + + // Verify - success on first attempt, no retries needed assertNotNull(result); - assertEquals(1, result.size()); - verify(clientMock, times(1)).loadWatchedSettings(any(SettingSelector.class), any(Context.class)); + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); + verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true)); } @Test - public void createWatchedConfigurationSettingsWithMultipleLabelsTest() throws Exception { - // Setup selector with multiple labels + public void startupRetriesAfterClientFailureThenSucceedsTest() throws IOException { + // Setup selector AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); selector.setKeyFilter(KEY_FILTER); - selector.setLabelFilter("dev,prod,test"); + selector.setLabelFilter(LABEL_FILTER); configStore.getSelects().add(selector); - // Setup mocks - when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) - .thenReturn(watchedConfigurationSettingsMock); - // Test - AzureAppConfigDataLoader loader = createLoader(); - List result = invokeGetWatchedConfigurationSettings(loader, clientMock); - - // Verify - should create watched configuration settings for each label + // Create a second client mock for the successful retry + AppConfigurationReplicaClient secondClientMock = Mockito.mock(AppConfigurationReplicaClient.class); + lenient().when(secondClientMock.getEndpoint()).thenReturn(ENDPOINT); + + // Setup mocks: + // - First getNextActiveClient(true) returns clientMock which will throw + // - First getNextActiveClient(false) returns null (no more replicas in first attempt) + // - Second getNextActiveClient(true) returns null (simulating success path) + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))) + .thenReturn(clientMock) // First attempt - will fail + .thenReturn(null); // Second attempt - no clients, treated as success + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))) + .thenReturn(null); // No more replicas + when(clientMock.getEndpoint()).thenReturn(ENDPOINT); + when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure")); + + // Test using public load() method + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, resource); + + // Verify - retried after failure assertNotNull(result); - assertEquals(3, result.size()); - verify(clientMock, times(3)).loadWatchedSettings(any(SettingSelector.class), any(Context.class)); + verify(replicaClientFactoryMock, atLeast(2)).findActiveClients(ENDPOINT); } @Test - public void refreshAllEnabledUsesWatchedConfigurationSettingsTest() throws Exception { - // Setup monitoring with refreshAll enabled - AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); - monitoring.setEnabled(true); - configStore.setMonitoring(monitoring); + public void startupFailsAfterAllRetriesExhaustedTest() { + // Setup with a short timeout + Profiles profiles = Mockito.mock(Profiles.class); + when(profiles.getActive()).thenReturn(List.of(LABEL_FILTER)); + + ConfigStore shortTimeoutStore = new ConfigStore(); + shortTimeoutStore.setEndpoint(ENDPOINT); + shortTimeoutStore.setEnabled(true); + FeatureFlagStore featureFlagStore = new FeatureFlagStore(); + featureFlagStore.setEnabled(false); + shortTimeoutStore.setFeatureFlags(featureFlagStore); + + AzureAppConfigDataResource shortTimeoutResource = new AzureAppConfigDataResource( + true, shortTimeoutStore, profiles, true, Duration.ofMinutes(1), Duration.ofSeconds(30)); + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + shortTimeoutStore.getSelects().add(selector); + + // Setup mocks - client always fails + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock); + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null); + when(clientMock.getEndpoint()).thenReturn(ENDPOINT); + when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure")); + + // Test using public load() method - should throw RuntimeException after retries exhausted + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + RuntimeException exception = assertThrows(RuntimeException.class, + () -> loader.load(configDataLoaderContextMock, shortTimeoutResource)); + + // Verify - failure after retries exhausted + assertTrue(exception.getMessage().contains("Failed to generate property sources")); + verify(replicaClientFactoryMock, atLeast(1)).findActiveClients(ENDPOINT); + } + + @Test + public void refreshOnlyAttemptsOnceOnFailureTest() throws IOException { // Setup selector AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); selector.setKeyFilter(KEY_FILTER); selector.setLabelFilter(LABEL_FILTER); configStore.getSelects().add(selector); - // Setup mocks - when(clientMock.loadWatchedSettings(any(SettingSelector.class), any(Context.class))) - .thenReturn(watchedConfigurationSettingsMock); + // Setup mocks - client fails + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock); + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null); + when(clientMock.getEndpoint()).thenReturn(ENDPOINT); + when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Simulated failure")); - // Test - verify that watched configuration settings are created when refreshAll is enabled - AzureAppConfigDataLoader loader = createLoader(); - List result = invokeGetWatchedConfigurationSettings(loader, clientMock); + // Test with refresh resource (isRefresh = true) - should NOT throw, just warn + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, refreshResource); - // Verify watched configuration settings were created + // Verify - only one findActiveClients call (no retry loop for refresh) assertNotNull(result); - assertEquals(1, result.size()); - verify(clientMock, times(1)).loadWatchedSettings(any(SettingSelector.class), any(Context.class)); + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); } @Test - public void refreshAllDisabledUsesWatchKeysTest() throws Exception { - // Setup monitoring with refreshAll disabled (traditional watch keys) - AppConfigurationStoreMonitoring monitoring = new AppConfigurationStoreMonitoring(); - monitoring.setEnabled(true); + public void refreshSucceedsOnFirstAttemptTest() throws IOException { + // Setup selector + AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); + selector.setKeyFilter(KEY_FILTER); + selector.setLabelFilter(LABEL_FILTER); + configStore.getSelects().add(selector); - // Add trigger for traditional watch key - AppConfigurationStoreTrigger trigger = new AppConfigurationStoreTrigger(); - trigger.setKey("sentinel"); - trigger.setLabel("prod"); - monitoring.setTriggers(List.of(trigger)); + // Setup mocks - no clients available (returns null = no-op success) + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(null); - configStore.setMonitoring(monitoring); + // Test with refresh resource + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, refreshResource); + // Verify - success on first attempt + assertNotNull(result); + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); + verify(replicaClientFactoryMock, times(1)).getNextActiveClient(eq(ENDPOINT), eq(true)); + } + + @Test + public void startupDoesNotRetryDuringRefreshTest() throws IOException { // Setup selector AppConfigurationKeyValueSelector selector = new AppConfigurationKeyValueSelector(); selector.setKeyFilter(KEY_FILTER); selector.setLabelFilter(LABEL_FILTER); configStore.getSelects().add(selector); - // Verify that when refreshAll is false, triggers are configured - // The actual validation happens in validateAndInit which is called during load - assertEquals(1, monitoring.getTriggers().size()); - assertEquals("sentinel", monitoring.getTriggers().get(0).getKey()); - } - - // Helper methods + // Setup mock - client throws exception + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(true))).thenReturn(clientMock); + when(replicaClientFactoryMock.getNextActiveClient(eq(ENDPOINT), eq(false))).thenReturn(null); + when(clientMock.getEndpoint()).thenReturn(ENDPOINT); + when(clientMock.listSettings(any(), any())).thenThrow(new RuntimeException("Test failure")); - 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); - } + // Test with refresh resource - should NOT throw, just warn and continue + AzureAppConfigDataLoader loader = new AzureAppConfigDataLoader(logFactoryMock); + ConfigData result = loader.load(configDataLoaderContextMock, refreshResource); - private List invokeGetWatchedConfigurationSettings( - AzureAppConfigDataLoader loader, AppConfigurationReplicaClient client) throws Exception { - // Set resource field in the loader using reflection - java.lang.reflect.Field resourceField = AzureAppConfigDataLoader.class.getDeclaredField("resource"); - resourceField.setAccessible(true); - resourceField.set(loader, resource); - - // Set requestContext field (it can be null for this test) - java.lang.reflect.Field requestContextField = AzureAppConfigDataLoader.class.getDeclaredField("requestContext"); - requestContextField.setAccessible(true); - requestContextField.set(loader, Context.NONE); - - // Use reflection to invoke private method - java.lang.reflect.Method method = AzureAppConfigDataLoader.class - .getDeclaredMethod("getWatchedConfigurationSettings", AppConfigurationReplicaClient.class); - method.setAccessible(true); - @SuppressWarnings("unchecked") - List result = (List) method.invoke(loader, client); - return result; + // Verify - failure on first attempt, no retry + assertNotNull(result); + // Only one findActiveClients call (would be multiple in startup retry loop) + verify(replicaClientFactoryMock, times(1)).findActiveClients(ENDPOINT); } } diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java index 0de265607483..68d0686497fd 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigDataResourceTest.java @@ -2,14 +2,15 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; - import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -32,6 +33,7 @@ class AzureAppConfigDataResourceTest { private static final String TEST_ENDPOINT = "https://test.azconfig.io"; private static final Duration TEST_REFRESH_INTERVAL = Duration.ofSeconds(30); + private static final Duration TEST_STARTUP_TIMEOUT = Duration.ofSeconds(100); private ConfigStore configStore; private AppConfigurationStoreMonitoring monitoring; @@ -57,7 +59,7 @@ void testConfigStoreEnabledState(boolean appConfigEnabled, boolean configStoreEn configStore.setEnabled(configStoreEnabled); AzureAppConfigDataResource resource = new AzureAppConfigDataResource( - appConfigEnabled, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL); + appConfigEnabled, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT); assertEquals(expectedEnabled, resource.isConfigStoreEnabled(), description); } @@ -71,7 +73,7 @@ void testEnabledStateWithRefreshScenarios(boolean isRefresh, String scenarioDesc configStore.setEnabled(true); AzureAppConfigDataResource resource = new AzureAppConfigDataResource( - true, configStore, mockProfiles, isRefresh, TEST_REFRESH_INTERVAL); + true, configStore, mockProfiles, isRefresh, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT); assertTrue(resource.isConfigStoreEnabled(), "Config store should be enabled in " + scenarioDescription + " when conditions are met"); @@ -91,14 +93,14 @@ void testAllPropertiesSetCorrectlyRegardlessOfEnabledState() { configStore.setEnabled(true); AzureAppConfigDataResource enabledResource = new AzureAppConfigDataResource( - true, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL); + true, configStore, mockProfiles, false, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT); assertTrue(enabledResource.isConfigStoreEnabled()); assertAllPropertiesCorrect(enabledResource, trimKeyPrefixes, selects, featureFlagSelects, true); configStore.setEnabled(false); AzureAppConfigDataResource disabledResource = new AzureAppConfigDataResource( - true, configStore, mockProfiles, true, TEST_REFRESH_INTERVAL); + true, configStore, mockProfiles, true, TEST_REFRESH_INTERVAL, TEST_STARTUP_TIMEOUT); assertFalse(disabledResource.isConfigStoreEnabled()); assertAllPropertiesCorrect(disabledResource, trimKeyPrefixes, selects, featureFlagSelects, false); @@ -123,13 +125,13 @@ private void assertAllPropertiesCorrect(AzureAppConfigDataResource resource, void testNullRefreshIntervalHandling() { configStore.setEnabled(true); AzureAppConfigDataResource enabledResource = new AzureAppConfigDataResource( - true, configStore, mockProfiles, false, null); + true, configStore, mockProfiles, false, null, TEST_STARTUP_TIMEOUT); assertTrue(enabledResource.isConfigStoreEnabled()); assertNull(enabledResource.getRefreshInterval()); configStore.setEnabled(false); AzureAppConfigDataResource disabledResource = new AzureAppConfigDataResource( - false, configStore, mockProfiles, true, null); + false, configStore, mockProfiles, true, null, TEST_STARTUP_TIMEOUT); assertFalse(disabledResource.isConfigStoreEnabled()); assertNull(disabledResource.getRefreshInterval()); assertFalse(disabledResource.isRefresh()); diff --git a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java index 787138a67a57..7753e68e67c7 100644 --- a/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java +++ b/sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManagerTest.java @@ -2,27 +2,29 @@ // Licensed under the MIT License. package com.azure.spring.cloud.appconfiguration.config.implementation; +import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + import java.time.Instant; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.AfterEach; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.Mockito; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import org.mockito.MockitoAnnotations; import org.mockito.MockitoSession; import org.mockito.quality.Strictness; import com.azure.spring.cloud.appconfiguration.config.AppConfigurationStoreHealth; -import static com.azure.spring.cloud.appconfiguration.config.implementation.TestConstants.TEST_ENDPOINT; import com.azure.spring.cloud.appconfiguration.config.implementation.autofailover.ReplicaLookUp; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.AppConfigurationStoreMonitoring; import com.azure.spring.cloud.appconfiguration.config.implementation.properties.ConfigStore; @@ -101,8 +103,8 @@ public void backoffTest() { configStore = new ConfigStore(); List endpoints = new ArrayList<>(); - endpoints.add("https://fake.test.config.io"); - endpoints.add("https://fake.test.geo.config.io"); + endpoints.add("fake.test.endpoint.one"); + endpoints.add("fake.test.endpoint.two"); configStore.setEndpoints(endpoints); @@ -419,7 +421,7 @@ public void getAvailableClientsWithAutoFailoverTest() { // Mock auto-failover endpoints List autoFailoverEndpoints = new ArrayList<>(); - String failoverEndpoint = "https://failover.test.config.io"; + String failoverEndpoint = "fake.test.failover.endpoint"; autoFailoverEndpoints.add(failoverEndpoint); when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); @@ -446,7 +448,7 @@ public void backoffAutoFailoverClientTest() { // Set up auto-failover scenario List autoFailoverEndpoints = new ArrayList<>(); - String failoverEndpoint = "https://failover.test.config.io"; + String failoverEndpoint = "fake.test.failover.endpoint"; autoFailoverEndpoints.add(failoverEndpoint); when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); @@ -543,7 +545,7 @@ public void getAvailableClientsWithLoadBalancingMixedBackoffTest() { // Mock auto-failover endpoints (but they should also be backed off) List autoFailoverEndpoints = new ArrayList<>(); - String failoverEndpoint = "https://failover.test.config.io"; + String failoverEndpoint = "fake.test.failover.endpoint"; autoFailoverEndpoints.add(failoverEndpoint); when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); @@ -579,5 +581,119 @@ public void getAvailableClientsSingleClientTest() { assertEquals(AppConfigurationStoreHealth.UP, manager.getHealth()); } + /** + * Tests getMillisUntilNextClientAvailable returns 0 when a client is available immediately. + */ + @Test + public void getMillisUntilNextClientAvailableReturnsZeroWhenClientAvailableTest() { + ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock); + + List clients = new ArrayList<>(); + clients.add(replicaClient1); + + // Client is not in backoff (available now) + when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().minusSeconds(60)); + when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients); + + // Initialize clients by calling getAvailableClients + manager.getAvailableClients(); + + long waitTime = manager.getMillisUntilNextClientAvailable(); + assertEquals(0, waitTime); + } + + /** + * Tests getMillisUntilNextClientAvailable returns wait time when all clients are in backoff. + */ + @Test + public void getMillisUntilNextClientAvailableReturnsWaitTimeWhenAllBackedOffTest() { + ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock); + + List clients = new ArrayList<>(); + clients.add(replicaClient1); + clients.add(replicaClient2); + + // Both clients are in backoff + Instant backoffEnd1 = Instant.now().plusSeconds(10); + Instant backoffEnd2 = Instant.now().plusSeconds(5); // This one expires sooner + when(replicaClient1.getBackoffEndTime()).thenReturn(backoffEnd1); + when(replicaClient2.getBackoffEndTime()).thenReturn(backoffEnd2); + when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients); + + // Initialize clients by calling getAvailableClients + manager.getAvailableClients(); + + long waitTime = manager.getMillisUntilNextClientAvailable(); + + // Should return approximately 5 seconds (the earlier backoff end time) + assertTrue(waitTime > 0); + assertTrue(waitTime <= 5000); + } + + /** + * Tests getMillisUntilNextClientAvailable considers auto-failover clients. + */ + @Test + public void getMillisUntilNextClientAvailableWithAutoFailoverClientTest() { + ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock); + + List clients = new ArrayList<>(); + clients.add(replicaClient1); + + // Regular client is in backoff for longer + when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(30)); + when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients); + + // Setup auto-failover client with shorter backoff + List autoFailoverEndpoints = new ArrayList<>(); + String failoverEndpoint = "fake.test.failover.endpoint"; + autoFailoverEndpoints.add(failoverEndpoint); + when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); + + // Auto-failover client expires sooner + when(autoFailoverClient.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(5)); + when(clientBuilderMock.buildClient(Mockito.eq(failoverEndpoint), Mockito.eq(configStore))).thenReturn(autoFailoverClient); + + // Initialize clients (will also add auto-failover client) + manager.getAvailableClients(); + + long waitTime = manager.getMillisUntilNextClientAvailable(); + + // Should return approximately 5 seconds (auto-failover client expires first) + assertTrue(waitTime > 0); + assertTrue(waitTime <= 5000); + } + + /** + * Tests getMillisUntilNextClientAvailable returns 0 when auto-failover client is available. + */ + @Test + public void getMillisUntilNextClientAvailableAutoFailoverAvailableTest() { + ConnectionManager manager = new ConnectionManager(clientBuilderMock, configStore, replicaLookUpMock); + + List clients = new ArrayList<>(); + clients.add(replicaClient1); + + // Regular client is in backoff + when(replicaClient1.getBackoffEndTime()).thenReturn(Instant.now().plusSeconds(30)); + when(clientBuilderMock.buildClients(Mockito.eq(configStore))).thenReturn(clients); + + // Setup auto-failover client that is available + List autoFailoverEndpoints = new ArrayList<>(); + String failoverEndpoint = "fake.test.failover.endpoint"; + autoFailoverEndpoints.add(failoverEndpoint); + when(replicaLookUpMock.getAutoFailoverEndpoints(Mockito.eq(TEST_ENDPOINT))).thenReturn(autoFailoverEndpoints); + + // Auto-failover client is available (not in backoff) + when(autoFailoverClient.getBackoffEndTime()).thenReturn(Instant.now().minusSeconds(60)); + when(clientBuilderMock.buildClient(Mockito.eq(failoverEndpoint), Mockito.eq(configStore))).thenReturn(autoFailoverClient); + + // Initialize clients (will also add auto-failover client) + manager.getAvailableClients(); + + long waitTime = manager.getMillisUntilNextClientAvailable(); + assertEquals(0, waitTime); + } + } diff --git a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md index 4c8d77d1de33..9ec8857ef887 100644 --- a/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md +++ b/sdk/spring/spring-cloud-azure-starter-appconfiguration-config/README.md @@ -56,7 +56,8 @@ Name | Description | Required | Default ---|---|---|--- spring.cloud.azure.appconfiguration.stores | List of configuration stores from which to load configuration properties | Yes | true spring.cloud.azure.appconfiguration.enabled | Whether enable spring-cloud-azure-appconfiguration-config or not | No | true -spring.cloud.azure.appconfiguration.refresh-interval | Amount of time, of type Duration, configurations are stored before a check can occur. | No | null +spring.cloud.azure.appconfiguration.refresh-interval | Amount of time, of type Duration, configurations are stored before a check can occur. | No | null +spring.cloud.azure.appconfiguration.startup-timeout | Maximum time to retry loading configuration during application startup when transient failures occur. | No | 100s `spring.cloud.azure.appconfiguration.stores` is a list of stores, where each store follows the following format: