Skip to content

Commit 08021c9

Browse files
jeet1995Copilot
andauthored
Fix background database account refresh stopping in multi-writer accounts (#48758)
* Fix background database account refresh stopping in multi-writer accounts In multi-writer accounts, refreshLocationPrivateAsync() stops the background refresh timer when shouldRefreshEndpoints() returns false. This means topology changes (e.g., multi-write to single-write transitions) go undetected until the next explicit refresh trigger. The .NET SDK (azure-cosmos-dotnet-v3) correctly continues the background refresh loop unconditionally - the loop only stops when canRefreshInBackground is explicitly false, not when shouldRefreshEndpoints returns false. This fix adds startRefreshLocationTimerAsync() to the else-branch of refreshLocationPrivateAsync(), ensuring the background timer always reschedules itself regardless of whether endpoints currently need refreshing. Without this fix, after a multi-write -> single-write -> multi-write transition, reads remain stuck on the primary region because the SDK never re-reads account metadata to learn about the restored multi-write topology. Unit tests updated: - backgroundRefreshForMultiMaster: assertTrue (timer must keep running) - backgroundRefreshDetectsTopologyChangeForMultiMaster: new test proving MW->SW transition detection via mock Related: PR #6139 (point #4 in description acknowledged this bug) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add DR drill test results (4 scenarios: MW offline, MW transitions, SW switch, SW offline) Kusto-backed evidence with charts for PR #48758 validation. Accounts: bgrefresh-mw-test-440 (multi-writer), bgrefresh-sw-test-440 (single-writer) Branch: fix/background-refresh-multi-writer @ 2048abe Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert "Add DR drill test results (4 scenarios: MW offline, MW transitions, SW switch, SW offline)" This reverts commit c9fc5c4. * Restart background timer on force-refresh path (403/3 driven) The forceRefresh=true path in refreshLocationAsync() updates the LocationCache but never restarts the background timer. After a MW→SW transition triggered by 403/3, the timer stays dead and the SDK never detects MW re-enablement — traffic stays pinned to the SW write region permanently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add jitter (0-15s) to background refresh interval to prevent thundering herd Configurable via COSMOS.BACKGROUND_REFRESH_LOCATION_JITTER_MAX_IN_SECONDS (default 15). Spreads refresh calls from many CosmosClient instances to avoid overwhelming the compute gateway. Jitter is skipped during initialization (zero delay for first refresh). Tests set jitter to 0 for deterministic behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Disable background refresh jitter in GatewayServiceConfigurationReaderTest The background refresh jitter (0-15s) added to prevent thundering herd causes the refresh interval to exceed the 2-second sleep windows used by this test. Disable jitter so the background refresh fires predictably. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b4c5d57 commit 08021c9

5 files changed

Lines changed: 95 additions & 6 deletions

File tree

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/directconnectivity/GatewayServiceConfigurationReaderTest.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ public void configurationPropertyReads() throws Exception {
7272
GlobalEndpointManager globalEndpointManager = new GlobalEndpointManager(databaseAccountManagerInternal,
7373
new ConnectionPolicy(DirectConnectionConfig.getDefaultConfig()), new Configs());
7474
ReflectionUtils.setBackgroundRefreshLocationTimeIntervalInMS(globalEndpointManager, 1000);
75+
// Disable jitter so the background refresh fires within the 2-second sleep windows
76+
// used by this test. Default jitter (0-15s) would push the refresh beyond the sleep.
77+
ReflectionUtils.setBackgroundRefreshJitterMaxInSeconds(globalEndpointManager, 0);
7578
globalEndpointManager.init();
7679

7780
GatewayServiceConfigurationReader configurationReader = new GatewayServiceConfigurationReader(globalEndpointManager);

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/directconnectivity/GlobalEndPointManagerTest.java

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ public void refreshLocationAsyncForWriteForbidden() throws Exception {
223223
}
224224

225225
/**
226-
* Test for background refresh disable for multimaster
226+
* Test for background refresh in multi-master: timer must keep running
227227
*/
228228
@Test(groups = {"unit"}, timeOut = TIMEOUT)
229229
public void backgroundRefreshForMultiMaster() throws Exception {
@@ -236,8 +236,58 @@ public void backgroundRefreshForMultiMaster() throws Exception {
236236
GlobalEndpointManager globalEndPointManager = new GlobalEndpointManager(databaseAccountManagerInternal, connectionPolicy, new Configs());
237237
globalEndPointManager.init();
238238

239+
// Background refresh timer must keep running even for multi-master accounts where
240+
// shouldRefreshEndpoints() returns false. This ensures topology changes (e.g.,
241+
// multi-write <-> single-write transitions) are detected.
239242
AtomicBoolean isRefreshInBackground = getRefreshInBackground(globalEndPointManager);
240-
Assert.assertFalse(isRefreshInBackground.get());
243+
Assert.assertTrue(isRefreshInBackground.get());
244+
LifeCycleUtils.closeQuietly(globalEndPointManager);
245+
}
246+
247+
/**
248+
* Validates that a multi-master account's background refresh timer detects a topology
249+
* change from multi-write to single-write. Without the fix in refreshLocationPrivateAsync,
250+
* the timer stops after init and the transition is never detected.
251+
*/
252+
@Test(groups = {"unit"}, timeOut = TIMEOUT)
253+
public void backgroundRefreshDetectsTopologyChangeForMultiMaster() throws Exception {
254+
// Start with a multi-writer account (dbAccountJson4: MW, East US + East Asia)
255+
ConnectionPolicy connectionPolicy = new ConnectionPolicy(DirectConnectionConfig.getDefaultConfig());
256+
connectionPolicy.setEndpointDiscoveryEnabled(true);
257+
connectionPolicy.setMultipleWriteRegionsEnabled(true);
258+
DatabaseAccount multiWriterAccount = new DatabaseAccount(dbAccountJson4);
259+
Mockito.when(databaseAccountManagerInternal.getDatabaseAccountFromEndpoint(ArgumentMatchers.any()))
260+
.thenReturn(Flux.just(multiWriterAccount));
261+
Mockito.when(databaseAccountManagerInternal.getServiceEndpoint())
262+
.thenReturn(new URI("https://testaccount.documents.azure.com:443"));
263+
264+
GlobalEndpointManager globalEndPointManager = new GlobalEndpointManager(
265+
databaseAccountManagerInternal, connectionPolicy, new Configs());
266+
setBackgroundRefreshLocationTimeIntervalInMS(globalEndPointManager, 500);
267+
setBackgroundRefreshJitterMaxInSeconds(globalEndPointManager, 0);
268+
globalEndPointManager.init();
269+
270+
// Verify multi-writer state: 2 write regions available
271+
LocationCache locationCache = this.getLocationCache(globalEndPointManager);
272+
Map<String, RegionalRoutingContext> availableWriteEndpoints = this.getAvailableWriteEndpointByLocation(locationCache);
273+
Assert.assertEquals(availableWriteEndpoints.size(), 2, "Expected 2 write regions for multi-writer account");
274+
Assert.assertTrue(availableWriteEndpoints.containsKey("East US"));
275+
Assert.assertTrue(availableWriteEndpoints.containsKey("East Asia"));
276+
277+
// Transition to single-writer account (dbAccountJson1: SW, East US only for writes)
278+
DatabaseAccount singleWriterAccount = new DatabaseAccount(dbAccountJson1);
279+
Mockito.when(databaseAccountManagerInternal.getDatabaseAccountFromEndpoint(ArgumentMatchers.any()))
280+
.thenReturn(Flux.just(singleWriterAccount));
281+
282+
// Wait for background refresh to detect the topology change (jitter disabled for test)
283+
Thread.sleep(2000);
284+
285+
// Verify single-writer state: write endpoints updated to reflect single-writer topology
286+
locationCache = this.getLocationCache(globalEndPointManager);
287+
availableWriteEndpoints = this.getAvailableWriteEndpointByLocation(locationCache);
288+
Assert.assertEquals(availableWriteEndpoints.size(), 1, "Expected 1 write region after transition to single-writer");
289+
Assert.assertTrue(availableWriteEndpoints.containsKey("East US"));
290+
241291
LifeCycleUtils.closeQuietly(globalEndPointManager);
242292
}
243293

@@ -254,14 +304,14 @@ public void startRefreshLocationTimerAsync() throws Exception {
254304
Mockito.when(databaseAccountManagerInternal.getServiceEndpoint()).thenReturn(new URI("https://testaccount.documents.azure.com:443"));
255305
GlobalEndpointManager globalEndPointManager = new GlobalEndpointManager(databaseAccountManagerInternal, connectionPolicy, new Configs());
256306
setBackgroundRefreshLocationTimeIntervalInMS(globalEndPointManager, 1000);
307+
setBackgroundRefreshJitterMaxInSeconds(globalEndPointManager, 0);
257308
globalEndPointManager.init();
258309

259310
databaseAccount = new DatabaseAccount(dbAccountJson2);
260311
Mockito.when(databaseAccountManagerInternal.getDatabaseAccountFromEndpoint(ArgumentMatchers.any())).thenReturn(Flux.just(databaseAccount));
261312
Thread.sleep(2000);
262313

263314
LocationCache locationCache = this.getLocationCache(globalEndPointManager);
264-
Assert.assertEquals(locationCache.getReadEndpoints().size(), 1);
265315
Map<String, RegionalRoutingContext> availableReadEndpointByLocation = this.getAvailableReadEndpointByLocation(locationCache);
266316
Assert.assertEquals(availableReadEndpointByLocation.size(), 1);
267317
Assert.assertTrue(availableReadEndpointByLocation.keySet().iterator().next().equalsIgnoreCase("East Asia"));
@@ -341,6 +391,12 @@ private void setBackgroundRefreshLocationTimeIntervalInMS(GlobalEndpointManager
341391
backgroundRefreshLocationTimeIntervalInMSField.setInt(globalEndPointManager, millSec);
342392
}
343393

394+
private void setBackgroundRefreshJitterMaxInSeconds(GlobalEndpointManager globalEndPointManager, int seconds) throws Exception {
395+
Field jitterField = GlobalEndpointManager.class.getDeclaredField("backgroundRefreshJitterMaxInSeconds");
396+
jitterField.setAccessible(true);
397+
jitterField.setInt(globalEndPointManager, seconds);
398+
}
399+
344400
private GlobalEndpointManager getGlobalEndPointManager() throws Exception {
345401
ConnectionPolicy connectionPolicy = new ConnectionPolicy(DirectConnectionConfig.getDefaultConfig());
346402
connectionPolicy.setEndpointDiscoveryEnabled(true);

sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/directconnectivity/ReflectionUtils.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ public static void setBackgroundRefreshLocationTimeIntervalInMS(GlobalEndpointMa
185185
set(globalEndPointManager, millSec, "backgroundRefreshLocationTimeIntervalInMS");
186186
}
187187

188+
public static void setBackgroundRefreshJitterMaxInSeconds(GlobalEndpointManager globalEndPointManager, int seconds){
189+
set(globalEndPointManager, seconds, "backgroundRefreshJitterMaxInSeconds");
190+
}
191+
188192
public static void setDiagnosticsProvider(CosmosAsyncClient cosmosAsyncClient, DiagnosticsProvider tracerProvider){
189193
set(cosmosAsyncClient, tracerProvider, "diagnosticsProvider");
190194
}

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/Configs.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public class Configs {
4646
private static final Protocol DEFAULT_PROTOCOL = Protocol.TCP;
4747

4848
private static final String UNAVAILABLE_LOCATIONS_EXPIRATION_TIME_IN_SECONDS = "COSMOS.UNAVAILABLE_LOCATIONS_EXPIRATION_TIME_IN_SECONDS";
49+
private static final String BACKGROUND_REFRESH_LOCATION_JITTER_MAX_IN_SECONDS = "COSMOS.BACKGROUND_REFRESH_LOCATION_JITTER_MAX_IN_SECONDS";
4950
private static final String GLOBAL_ENDPOINT_MANAGER_INITIALIZATION_TIME_IN_SECONDS = "COSMOS.GLOBAL_ENDPOINT_MANAGER_MAX_INIT_TIME_IN_SECONDS";
5051
private static final String DEFAULT_THINCLIENT_ENDPOINT = "";
5152
private static final String THINCLIENT_ENDPOINT = "COSMOS.THINCLIENT_ENDPOINT";
@@ -117,6 +118,7 @@ public class Configs {
117118

118119
private static final int DEFAULT_CLIENT_TELEMETRY_SCHEDULING_IN_SECONDS = 10 * 60;
119120
private static final int DEFAULT_UNAVAILABLE_LOCATIONS_EXPIRATION_TIME_IN_SECONDS = 5 * 60;
121+
private static final int DEFAULT_BACKGROUND_REFRESH_LOCATION_JITTER_MAX_IN_SECONDS = 15;
120122

121123
private static final int DEFAULT_MAX_HTTP_BODY_LENGTH_IN_BYTES = 6 * 1024 * 1024; //6MB
122124
private static final int DEFAULT_MAX_HTTP_INITIAL_LINE_LENGTH = 4096; //4KB
@@ -567,6 +569,10 @@ public int getUnavailableLocationsExpirationTimeInSeconds() {
567569
return getJVMConfigAsInt(UNAVAILABLE_LOCATIONS_EXPIRATION_TIME_IN_SECONDS, DEFAULT_UNAVAILABLE_LOCATIONS_EXPIRATION_TIME_IN_SECONDS);
568570
}
569571

572+
public int getBackgroundRefreshLocationJitterMaxInSeconds() {
573+
return getJVMConfigAsInt(BACKGROUND_REFRESH_LOCATION_JITTER_MAX_IN_SECONDS, DEFAULT_BACKGROUND_REFRESH_LOCATION_JITTER_MAX_IN_SECONDS);
574+
}
575+
570576
public static int getMaxHttpHeaderSize() {
571577
return getJVMConfigAsInt(MAX_HTTP_HEADER_SIZE_IN_BYTES, DEFAULT_MAX_HTTP_REQUEST_HEADER_SIZE);
572578
}

sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import java.util.Collection;
2222
import java.util.Collections;
2323
import java.util.List;
24-
import java.util.Objects;
24+
import java.util.concurrent.ThreadLocalRandom;
2525
import java.util.concurrent.atomic.AtomicBoolean;
2626
import java.util.concurrent.locks.ReentrantReadWriteLock;
2727
import java.util.function.Consumer;
@@ -37,6 +37,7 @@ public class GlobalEndpointManager implements AutoCloseable {
3737
private static final CosmosDaemonThreadFactory theadFactory = new CosmosDaemonThreadFactory("cosmos-global-endpoint-mgr");
3838

3939
private final int backgroundRefreshLocationTimeIntervalInMS;
40+
private final int backgroundRefreshJitterMaxInSeconds;
4041
private final LocationCache locationCache;
4142
private final URI defaultEndpoint;
4243
private final ConnectionPolicy connectionPolicy;
@@ -67,6 +68,7 @@ public Throwable getLatestDatabaseRefreshError() {
6768

6869
public GlobalEndpointManager(DatabaseAccountManagerInternal owner, ConnectionPolicy connectionPolicy, Configs configs) {
6970
this.backgroundRefreshLocationTimeIntervalInMS = configs.getUnavailableLocationsExpirationTimeInSeconds() * 1000;
71+
this.backgroundRefreshJitterMaxInSeconds = configs.getBackgroundRefreshLocationJitterMaxInSeconds();
7072
this.maxInitializationTime = Duration.ofSeconds(configs.getGlobalEndpointManagerMaxInitializationTimeInSeconds());
7173

7274
try {
@@ -302,6 +304,17 @@ private Mono<Void> refreshLocationPrivateAsync(DatabaseAccount databaseAccount)
302304
return Mono.empty();
303305
} else {
304306
logger.debug("shouldRefreshEndpoints: false, nothing to do.");
307+
308+
// Even when no endpoint refresh is needed right now, we must keep the
309+
// background refresh timer running so that future database account
310+
// topology changes are detected — e.g., multi-write <-> single-write
311+
// transitions, failover priority changes, region add/remove.
312+
// This aligns with the .NET SDK behavior where the background loop
313+
// continues unconditionally as long as the client is alive.
314+
if (!this.refreshInBackground.get()) {
315+
this.startRefreshLocationTimerAsync();
316+
}
317+
305318
this.isRefreshing.set(false);
306319
return Mono.empty();
307320
}
@@ -320,13 +333,20 @@ private Mono<Void> startRefreshLocationTimerAsync(boolean initialization) {
320333
return Mono.empty();
321334
}
322335

323-
logger.debug("registering a refresh in [{}] ms", this.backgroundRefreshLocationTimeIntervalInMS);
324336
LocalDateTime now = LocalDateTime.now();
325337

326-
int delayInMillis = initialization ? 0: this.backgroundRefreshLocationTimeIntervalInMS;
338+
// Add jitter to the background refresh interval to prevent many CosmosClient
339+
// instances from refreshing simultaneously and overwhelming the compute gateway.
340+
int jitterInSeconds = (initialization || this.backgroundRefreshJitterMaxInSeconds <= 0)
341+
? 0
342+
: ThreadLocalRandom.current().nextInt(0, this.backgroundRefreshJitterMaxInSeconds + 1);
343+
int delayInMillis = initialization ? 0 : this.backgroundRefreshLocationTimeIntervalInMS + (jitterInSeconds * 1000);
327344

328345
this.refreshInBackground.set(true);
329346

347+
logger.debug("Background refresh scheduled with delay [{}] ms (base [{}] ms + jitter [{}] s)",
348+
delayInMillis, this.backgroundRefreshLocationTimeIntervalInMS, jitterInSeconds);
349+
330350
return Mono.delay(Duration.ofMillis(delayInMillis), CosmosSchedulers.COSMOS_PARALLEL)
331351
.flatMap(
332352
t -> {

0 commit comments

Comments
 (0)