Skip to content

App Config Spring - Refresh Refactor #47877

Open
mrm9084 wants to merge 25 commits intoAzure:mainfrom
mrm9084:RefactorRefresh
Open

App Config Spring - Refresh Refactor #47877
mrm9084 wants to merge 25 commits intoAzure:mainfrom
mrm9084:RefactorRefresh

Conversation

@mrm9084
Copy link
Member

@mrm9084 mrm9084 commented Feb 2, 2026

Description

  • Refactors how the state is controlled to work with newer systems instead of being static code.
  • Removes a few code warnings
  • Now contains a feature management bug fix for the week of daylight savings time where there is a day with less than 24 hours.

All SDK Contribution checklist:

  • The pull request does not introduce [breaking changes]
  • [] CHANGELOG is updated for new features, bug fixes or other significant changes.
  • I have read the contribution guidelines.

General Guidelines and Best Practices

  • Title of the pull request is clear and informative.
  • There are a small number of commits, each of which have an informative message. This means that previously merged commits do not appear in the history of the PR. For more information on cleaning up the commits in your PR, see this page.

Testing Guidelines

  • Pull request includes test coverage for the included changes.

Copilot AI review requested due to automatic review settings February 2, 2026 19:15
@github-actions github-actions bot added the azure-spring All azure-spring related issues label Feb 2, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 StateHolder from a static singleton to an instance-based class registered in Spring's BootstrapContext and promoted to the ApplicationContext
  • Updated AppConfigurationRefreshUtil to accept StateHolder as 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() > 0 with .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 (endpointvalidationEndpoint) 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

@rujche rujche added this to the 2026-02 milestone Feb 3, 2026
@rujche rujche moved this from Todo to In Progress in Spring Cloud Azure Feb 3, 2026
@rujche rujche added the azure-spring-app-configuration Spring app configuration related issues. label Feb 3, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • expireState dereferences oldState without a null check. If expireState is called for an endpoint that hasn't had setState(...) called yet (or state was cleared/never initialized), this will throw a NullPointerException. Add a guard (e.g., return early when oldState == 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

  • updateNextRefreshTime calls getNextRefreshCheck(nextForcedRefresh, ...) without handling nextForcedRefresh == null, which will NPE inside getNextRefreshCheck. Consider initializing nextForcedRefresh in the constructor (e.g., Instant.now()) and/or treating null as "expired" and setting a new value before calling getNextRefreshCheck.
    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;
        }

@rujche rujche requested a review from Copilot February 11, 2026 02:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.java still use the removed no-argument constructor new AppConfigurationRefreshUtil() and mock static StateHolder methods (e.g., StateHolder.getLoadState(), StateHolder.getState(), StateHolder.getCurrentState(), StateHolder.getStateFeatureFlag()). Since AppConfigurationRefreshUtil no longer has a no-argument constructor (it now requires a StateHolder instance), and the static methods on StateHolder have been removed, these tests will fail to compile. Affected tests include: refreshStoresCheckSettingsTestNotEnabled, refreshStoresCheckSettingsTestNotLoaded, refreshStoresCheckSettingsTestNotRefreshTime, refreshStoresCheckSettingsTestFailedRequest, refreshStoresCheckSettingsTestRefreshTimeNoChange, refreshStoresPushRefreshEnabledPrimary, refreshStoresPushRefreshEnabledSecondary, refreshStoresCheckSettingsTestTriggerRefresh, refreshStoresCheckFeatureFlagTestNotLoaded, refreshStoresCheckFeatureFlagTestNotRefreshTime, refreshStoresCheckFeatureFlagTestNoChange, refreshStoresCheckFeatureFlagTestTriggerRefresh, and refreshAllWithWatchedConfigurationSettingsTest. Each of these tests needs to be updated to use refreshUtil (the instance-based field) instead of new AppConfigurationRefreshUtil(), and mock the currentStateMock instead of using MockedStatic<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));
        }
    }

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

@rujche rujche requested a review from Copilot March 9, 2026 01:50
@rujche rujche modified the milestones: 2026-03, 2026-04 Mar 9, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 1 comment.

AppConfigurationReplicaClientFactory clientFactory = context.getOrElse(AppConfigurationReplicaClientFactory.class, null);
ReplicaLookUp replicaLookUp = context.getOrElse(ReplicaLookUp.class, null);

context.get(AppConfigurationReplicaClientFactory.class);
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Comment on lines +687 to 689
FeatureFlagStore disabledFeatureStore = new FeatureFlagStore();
disabledFeatureStore.setEnabled(false);
when(clientFactoryMock.getConnections()).thenReturn(Map.of(endpoint, connectionManagerMock));
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • updateNextRefreshTime calls getNextRefreshCheck(nextForcedRefresh, ...) when refreshInterval != null, but nextForcedRefresh can be null (per getNextForcedRefresh() JavaDoc and when setNextForcedRefresh hasn’t been called). This can cause a NullPointerException inside getNextRefreshCheck (and also in newForcedRefresh.compareTo(nextForcedRefresh)). Consider initializing nextForcedRefresh when first needed (e.g., treat null as expired and set to Instant.now()/Instant.EPOCH), or guard null explicitly before calling getNextRefreshCheck.
    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;

Comment on lines +41 to 49
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;
}

Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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();
}

Copilot uses AI. Check for mistakes.
Comment on lines +105 to +111
// 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)) {
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +18 to +28
* 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>
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

azure-spring All azure-spring related issues azure-spring-app-configuration Spring app configuration related issues.

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

3 participants