Conversation
There was a problem hiding this comment.
Pull request overview
This pull request refactors the state management in the Azure App Configuration Spring library, moving from a static singleton pattern to an instance-based approach managed by Spring's dependency injection framework.
Changes:
- Refactored
StateHolderfrom a static singleton to an instance-based class registered in Spring's BootstrapContext and promoted to the ApplicationContext - Updated
AppConfigurationRefreshUtilto acceptStateHolderas a constructor parameter instead of using static methods - Modified tests to use instance-based StateHolder instead of static methods, splitting monolithic tests into focused unit tests
- Applied minor code quality improvements including replacing
.size() > 0with.isEmpty()and improving variable naming
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| StateHolder.java | Converted from package-private singleton to public instance-based class; removed static methods and singleton pattern |
| StateHolderTest.java | Refactored from single monolithic test to multiple focused tests using instance-based StateHolder |
| AppConfigurationRefreshUtil.java | Added StateHolder constructor parameter; converted static refresh methods to instance methods |
| AppConfigurationRefreshUtilTest.java | Updated to use mocked StateHolder instances instead of static method mocking |
| AppConfigurationPullRefresh.java | Added StateHolder constructor parameter and injected dependency |
| AppConfigurationPullRefreshTest.java | Updated constructor calls to include StateHolder mock; reorganized imports |
| AzureAppConfigBoostrapRegistrar.java | Added StateHolder registration in BootstrapContext with promotion to ApplicationContext |
| AzureAppConfigDataLoader.java | Modified to retrieve StateHolder from BootstrapContext instead of creating new instance |
| AppConfigurationWatchAutoConfiguration.java | Updated bean creation to retrieve StateHolder from BootstrapContext and pass to dependencies |
| ConnectionManager.java | Simplified client tracking by replacing currentReplica with lastActiveClient |
| ConfigStore.java | Improved variable naming (endpoint → validationEndpoint) and validation logic |
| AppConfigurationProperties.java | Replaced .size() > 0 with .isEmpty() for better code style |
| AppConfigurationReplicaClient.java | Replaced .size() > 0 with .isEmpty() for better code style |
| AppConfigurationApplicationSettingPropertySource.java | Added @OverRide annotation and removed unnecessary return statement from void method |
...ure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefreshTest.java
Outdated
Show resolved
Hide resolved
...src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
Show resolved
Hide resolved
...src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
Show resolved
Hide resolved
...m/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
Show resolved
Hide resolved
...m/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationPullRefresh.java
Show resolved
Hide resolved
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 7 comments.
Comments suppressed due to low confidence (2)
sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java:156
expireStatedereferencesoldStatewithout a null check. IfexpireStateis called for an endpoint that hasn't hadsetState(...)called yet (or state was cleared/never initialized), this will throw a NullPointerException. Add a guard (e.g., return early whenoldState == null) so expiring an unknown endpoint is a no-op.
public void expireState(String originEndpoint) {
State oldState = state.get(originEndpoint);
long wait = (long) (new SecureRandom().nextDouble() * MAX_JITTER);
long timeLeft = (int) ((oldState.getNextRefreshCheck().toEpochMilli() - (Instant.now().toEpochMilli())) / 1000);
if (wait < timeLeft) {
sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java:195
updateNextRefreshTimecallsgetNextRefreshCheck(nextForcedRefresh, ...)without handlingnextForcedRefresh == null, which will NPE insidegetNextRefreshCheck. Consider initializingnextForcedRefreshin the constructor (e.g.,Instant.now()) and/or treating null as "expired" and setting a new value before callinggetNextRefreshCheck.
public void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) {
if (refreshInterval != null) {
Instant newForcedRefresh = getNextRefreshCheck(nextForcedRefresh,
clientRefreshAttempts, refreshInterval.getSeconds(), defaultMinBackoff);
if (newForcedRefresh.compareTo(nextForcedRefresh) != 0) {
clientRefreshAttempts += 1;
}
nextForcedRefresh = newForcedRefresh;
}
...m/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
Outdated
Show resolved
Hide resolved
...m/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtil.java
Outdated
Show resolved
Hide resolved
...va/com/azure/spring/cloud/appconfiguration/config/implementation/properties/ConfigStore.java
Outdated
Show resolved
Hide resolved
...ure/spring/cloud/appconfiguration/config/implementation/AzureAppConfigBoostrapRegistrar.java
Outdated
Show resolved
Hide resolved
...ure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
Outdated
Show resolved
Hide resolved
...test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java
Outdated
Show resolved
Hide resolved
...test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java
Outdated
Show resolved
Hide resolved
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
sdk/spring/spring-cloud-azure-appconfiguration-config/src/test/java/com/azure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java:240
- Many tests in
AppConfigurationRefreshUtilTest.javastill use the removed no-argument constructornew AppConfigurationRefreshUtil()and mock staticStateHoldermethods (e.g.,StateHolder.getLoadState(),StateHolder.getState(),StateHolder.getCurrentState(),StateHolder.getStateFeatureFlag()). SinceAppConfigurationRefreshUtilno longer has a no-argument constructor (it now requires aStateHolderinstance), and the static methods onStateHolderhave been removed, these tests will fail to compile. Affected tests include:refreshStoresCheckSettingsTestNotEnabled,refreshStoresCheckSettingsTestNotLoaded,refreshStoresCheckSettingsTestNotRefreshTime,refreshStoresCheckSettingsTestFailedRequest,refreshStoresCheckSettingsTestRefreshTimeNoChange,refreshStoresPushRefreshEnabledPrimary,refreshStoresPushRefreshEnabledSecondary,refreshStoresCheckSettingsTestTriggerRefresh,refreshStoresCheckFeatureFlagTestNotLoaded,refreshStoresCheckFeatureFlagTestNotRefreshTime,refreshStoresCheckFeatureFlagTestNoChange,refreshStoresCheckFeatureFlagTestTriggerRefresh, andrefreshAllWithWatchedConfigurationSettingsTest. Each of these tests needs to be updated to userefreshUtil(the instance-based field) instead ofnew AppConfigurationRefreshUtil(), and mock thecurrentStateMockinstead of usingMockedStatic<StateHolder>.
try (MockedStatic<StateHolder> stateHolderMock = Mockito.mockStatic(StateHolder.class)) {
stateHolderMock.when(() -> StateHolder.getLoadState(endpoint)).thenReturn(false);
stateHolderMock.when(() -> StateHolder.getState(endpoint)).thenReturn(newState);
// Monitor is disabled
RefreshEventData eventData = new AppConfigurationRefreshUtil().refreshStoresCheck(clientFactoryMock,
Duration.ofMinutes(10),
(long) 60, replicaLookUpMock);
assertFalse(eventData.getDoRefresh());
verify(clientOriginMock, times(0)).getWatchKey(Mockito.anyString(), Mockito.anyString(),
Mockito.any(Context.class));
}
}
...in/java/com/azure/spring/cloud/appconfiguration/config/implementation/ConnectionManager.java
Outdated
Show resolved
Hide resolved
...ure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
Show resolved
Hide resolved
...ure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
Show resolved
Hide resolved
...ure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
Show resolved
Hide resolved
...a/com/azure/spring/cloud/appconfiguration/config/AppConfigurationWatchAutoConfiguration.java
Outdated
Show resolved
Hide resolved
...src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java
Show resolved
Hide resolved
...ure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
Show resolved
Hide resolved
...ure/spring/cloud/appconfiguration/config/implementation/AppConfigurationRefreshUtilTest.java
Show resolved
Hide resolved
...test/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolderTest.java
Outdated
Show resolved
Hide resolved
| AppConfigurationReplicaClientFactory clientFactory = context.getOrElse(AppConfigurationReplicaClientFactory.class, null); | ||
| ReplicaLookUp replicaLookUp = context.getOrElse(ReplicaLookUp.class, null); | ||
|
|
||
| context.get(AppConfigurationReplicaClientFactory.class); |
There was a problem hiding this comment.
Line 44 calls context.get(AppConfigurationReplicaClientFactory.class) but its return value is discarded — the factory was already retrieved with context.getOrElse(...) on line 41 into clientFactory. This is dead code that has no effect and creates unnecessary confusion for readers.
...pring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java
Outdated
Show resolved
Hide resolved
| FeatureFlagStore disabledFeatureStore = new FeatureFlagStore(); | ||
| disabledFeatureStore.setEnabled(false); | ||
| when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock)); |
There was a problem hiding this comment.
In watchedConfigurationSettingsWithChangeDetectedTest, a disabledFeatureStore is created and setEnabled(false) is called on it, but there is no when(connectionManagerMock.getFeatureFlagStore()).thenReturn(disabledFeatureStore) stub. Compare this with the similar test watchedConfigurationSettingsNoChangeTest at line 654-656 which correctly stubs this call. While the test likely passes because refreshStoresCheck returns early when doRefresh is true (before reaching connection.getFeatureFlagStore()), the setup is incomplete and leaves the disabledFeatureStore variable unused. This should be stubbed for correctness and test intent clarity.
...pring/cloud/feature/management/implementation/timewindow/recurrence/RecurrenceEvaluator.java
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
sdk/spring/spring-cloud-azure-appconfiguration-config/src/main/java/com/azure/spring/cloud/appconfiguration/config/implementation/StateHolder.java:197
updateNextRefreshTimecallsgetNextRefreshCheck(nextForcedRefresh, ...)whenrefreshInterval != null, butnextForcedRefreshcan be null (pergetNextForcedRefresh()JavaDoc and whensetNextForcedRefreshhasn’t been called). This can cause a NullPointerException insidegetNextRefreshCheck(and also innewForcedRefresh.compareTo(nextForcedRefresh)). Consider initializingnextForcedRefreshwhen first needed (e.g., treat null as expired and set toInstant.now()/Instant.EPOCH), or guard null explicitly before callinggetNextRefreshCheck.
public void updateNextRefreshTime(Duration refreshInterval, Long defaultMinBackoff) {
if (refreshInterval != null) {
Instant newForcedRefresh = getNextRefreshCheck(nextForcedRefresh,
clientRefreshAttempts, refreshInterval.getSeconds(), defaultMinBackoff);
if (newForcedRefresh.compareTo(nextForcedRefresh) != 0) {
clientRefreshAttempts += 1;
}
nextForcedRefresh = newForcedRefresh;
| AppConfigurationReplicaClientFactory clientFactory = context.getOrElse(AppConfigurationReplicaClientFactory.class, null); | ||
| ReplicaLookUp replicaLookUp = context.getOrElse(ReplicaLookUp.class, null); | ||
|
|
||
| StateHolder stateHolder = context.get(StateHolder.class); | ||
|
|
||
| if (clientFactory == null || replicaLookUp == null) { | ||
| return null; | ||
| } | ||
|
|
There was a problem hiding this comment.
BootstrapContext#get(StateHolder.class) will throw if StateHolder was never registered (e.g., when App Configuration config-data isn’t resolvable/used or stores are disabled). Since this call happens before the null checks for clientFactory/replicaLookUp, the auto-configuration can fail during startup instead of returning null. Use context.getOrElse(StateHolder.class, null) (or isRegistered check) and only require it when the other dependencies are present; fall back to constructing AppConfigurationRefreshUtil with a new StateHolder if needed.
| AppConfigurationReplicaClientFactory clientFactory = context.getOrElse(AppConfigurationReplicaClientFactory.class, null); | |
| ReplicaLookUp replicaLookUp = context.getOrElse(ReplicaLookUp.class, null); | |
| StateHolder stateHolder = context.get(StateHolder.class); | |
| if (clientFactory == null || replicaLookUp == null) { | |
| return null; | |
| } | |
| AppConfigurationReplicaClientFactory clientFactory = | |
| context.getOrElse(AppConfigurationReplicaClientFactory.class, null); | |
| ReplicaLookUp replicaLookUp = context.getOrElse(ReplicaLookUp.class, null); | |
| StateHolder stateHolder = context.getOrElse(StateHolder.class, null); | |
| if (clientFactory == null || replicaLookUp == null) { | |
| return null; | |
| } | |
| if (stateHolder == null) { | |
| stateHolder = new StateHolder(); | |
| } |
| // Handle DST transitions: If the calculated week start has a fixed offset (ZoneOffset) | ||
| // and 'now' has a region zone, check if they represent the same geographic location. | ||
| // We do this by checking if the fixed offset matches what the region zone's offset | ||
| // was at the *original start time*. If it matches, they're in the same timezone, | ||
| // and we should convert to the region zone for DST-aware comparisons. | ||
| if (firstDayOfMostRecentOccurringWeek.getZone() instanceof java.time.ZoneOffset | ||
| && !(now.getZone() instanceof java.time.ZoneOffset)) { |
There was a problem hiding this comment.
This DST-specific weekly recurrence logic isn’t covered by a unit test. Since there is already a comprehensive RecurrenceEvaluatorTest, add a test case that crosses a DST transition (e.g., America/New_York around the spring-forward week) and asserts the expected previous occurrence/match behavior so this regression stays protected.
| * Thread-safe holder for managing refresh state of Azure App Configuration stores. | ||
| * | ||
| * <p> | ||
| * 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. | ||
| * </p> | ||
| * | ||
| * <p> | ||
| * This class is registered in the BootstrapContext during initial configuration loading and promoted to the main | ||
| * ApplicationContext for use during refresh operations. | ||
| * </p> |
There was a problem hiding this comment.
PR description indicates a user-visible bug fix (DST week) and a refresh refactor, but the checklist shows CHANGELOG isn’t updated. Please add an entry to the appropriate module CHANGELOG(s) for the fix/refactor per Azure SDK release guidelines.
Description
All SDK Contribution checklist:
General Guidelines and Best Practices
Testing Guidelines