Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,11 @@
public class ExcludeRegionTests extends TestSuiteBase {
private static final int TIMEOUT = 60000;
private CosmosAsyncClient clientWithPreferredRegions;
private CosmosAsyncClient clientWithNonCanonicalPreferredRegions;
private CosmosAsyncContainer cosmosAsyncContainer;
private CosmosAsyncContainer cosmosAsyncContainerNonCanonical;
private List<String> preferredRegionList;
private List<String> nonCanonicalPreferredRegionList;

private static final CosmosEndToEndOperationLatencyPolicyConfig INF_E2E_TIMEOUT
= new CosmosEndToEndOperationLatencyPolicyConfigBuilder(Duration.ofDays(100)).build();
Expand Down Expand Up @@ -76,6 +79,23 @@ public void beforeClass() {
.buildAsyncClient();

this.cosmosAsyncContainer = getSharedSinglePartitionCosmosContainer(this.clientWithPreferredRegions);

// Build a second client with non-canonical preferred regions (space-stripped)
// e.g., "west us 3" → "westus3", "east us" → "eastus"
this.nonCanonicalPreferredRegionList = new ArrayList<>();
for (String region : this.preferredRegionList) {
this.nonCanonicalPreferredRegionList.add(region.replace(" ", ""));
}

this.clientWithNonCanonicalPreferredRegions =
this.getClientBuilder()
.contentResponseOnWriteEnabled(true)
.preferredRegions(this.nonCanonicalPreferredRegionList)
.multipleWriteRegionsEnabled(true)
.buildAsyncClient();

this.cosmosAsyncContainerNonCanonical =
getSharedSinglePartitionCosmosContainer(this.clientWithNonCanonicalPreferredRegions);
} finally {
safeClose(dummyClient);
}
Expand All @@ -84,6 +104,7 @@ public void beforeClass() {
@AfterClass(groups = {"multi-master"}, timeOut = SHUTDOWN_TIMEOUT, alwaysRun = true)
public void afterClass() {
safeClose(this.clientWithPreferredRegions);
safeClose(this.clientWithNonCanonicalPreferredRegions);
System.clearProperty("COSMOS.DEFAULT_SESSION_TOKEN_MISMATCH_INITIAL_BACKOFF_TIME_IN_MILLISECONDS");
System.clearProperty("COSMOS.DEFAULT_SESSION_TOKEN_MISMATCH_WAIT_TIME_IN_MILLISECONDS");
}
Expand Down Expand Up @@ -234,6 +255,78 @@ public void excludeRegionTest_readSessionNotAvailable(
}
}

@Test(groups = {"multi-master"}, dataProvider = "operationTypeArgProvider", timeOut = TIMEOUT)
public void excludeRegionTest_nonCanonicalPreferredRegions_shouldRouteCorrectly(OperationType operationType) throws InterruptedException {

if (this.nonCanonicalPreferredRegionList.size() <= 1) {
throw new SkipException("Test requires multi-master with multi-regions");
}

// Verify that a client built with space-stripped region names (e.g., "westus3")
// routes to the correct first preferred region — same as canonical names
TestObject createdItem = TestObject.create();
this.cosmosAsyncContainerNonCanonical.createItem(createdItem).block();

Thread.sleep(2000);

CosmosDiagnosticsContext diagnostics = this.performDocumentOperation(
cosmosAsyncContainerNonCanonical, operationType, createdItem, null, INF_E2E_TIMEOUT);

// The contacted region should match the first preferred region (lowercased canonical form)
validateRegionsContacted(diagnostics, this.preferredRegionList.subList(0, 1));
}

@Test(groups = {"multi-master"}, dataProvider = "operationTypeArgProvider", timeOut = TIMEOUT)
public void excludeRegionTest_nonCanonicalExcludeRegion_shouldSkipExcludedRegion(OperationType operationType) throws InterruptedException {

if (this.preferredRegionList.size() <= 1) {
throw new SkipException("Test requires multi-master with multi-regions");
}

TestObject createdItem = TestObject.create();
this.cosmosAsyncContainerNonCanonical.createItem(createdItem).block();

Thread.sleep(2000);

// Exclude the first preferred region using space-stripped name (e.g., "westus3")
String firstRegionNoSpaces = this.preferredRegionList.get(0).replace(" ", "");

CosmosDiagnosticsContext diagnosticsPostExclusion = this.performDocumentOperation(
cosmosAsyncContainerNonCanonical,
operationType,
createdItem,
Arrays.asList(firstRegionNoSpaces),
INF_E2E_TIMEOUT);

// Should route to the second preferred region, not the first
validateRegionsContacted(diagnosticsPostExclusion, this.preferredRegionList.subList(1, 2));
}

@Test(groups = {"multi-master"}, timeOut = TIMEOUT)
public void excludeRegionTest_uppercaseExcludeRegion_shouldSkipExcludedRegion() throws InterruptedException {

if (this.preferredRegionList.size() <= 1) {
throw new SkipException("Test requires multi-master with multi-regions");
}

TestObject createdItem = TestObject.create();
this.cosmosAsyncContainer.createItem(createdItem).block();

Thread.sleep(2000);

// Exclude the first preferred region using UPPERCASE name
String firstRegionUppercase = this.preferredRegionList.get(0).toUpperCase();

CosmosDiagnosticsContext diagnostics = this.performDocumentOperation(
cosmosAsyncContainer,
OperationType.Read,
createdItem,
Arrays.asList(firstRegionUppercase),
INF_E2E_TIMEOUT);

validateRegionsContacted(diagnostics, this.preferredRegionList.subList(1, 2));
}

private List<String> getPreferredRegionList(CosmosAsyncClient client) {
assertThat(client).isNotNull();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import com.azure.cosmos.models.CosmosClientTelemetryConfig;
import com.azure.cosmos.models.CosmosContainerProperties;
import com.azure.cosmos.models.CosmosItemIdentity;
import com.azure.cosmos.models.CosmosItemRequestOptions;
import com.azure.cosmos.models.CosmosItemResponse;
import com.azure.cosmos.models.CosmosPatchItemRequestOptions;
import com.azure.cosmos.models.CosmosPatchOperations;
Expand Down Expand Up @@ -54,6 +55,7 @@
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;

import java.time.Duration;
import java.time.Instant;
Expand Down Expand Up @@ -193,6 +195,7 @@ public abstract class FaultInjectionWithAvailabilityStrategyTestsBase extends Te
private String SECOND_REGION_NAME = null;

private List<String> writeableRegions;
private List<String> nonCanonicalWriteableRegions;

private String testDatabaseId;
private String testContainerId;
Expand Down Expand Up @@ -236,6 +239,12 @@ public void beforeClass() {
assertThat(this.writeableRegions).isNotNull();
assertThat(this.writeableRegions.size()).isGreaterThanOrEqualTo(2);

// Build non-canonical (space-stripped) region names for normalization tests
this.nonCanonicalWriteableRegions = new ArrayList<>();
for (String region : this.writeableRegions) {
this.nonCanonicalWriteableRegions.add(region.toLowerCase(Locale.ROOT).replace(" ", ""));
}

FIRST_REGION_NAME = this.writeableRegions.get(0).toLowerCase(Locale.ROOT);
SECOND_REGION_NAME = this.writeableRegions.get(1).toLowerCase(Locale.ROOT);

Expand Down Expand Up @@ -339,6 +348,65 @@ public void beforeClass() {
safeClose(dummyClient);
}
}

@Test(groups = {"fi-multi-master"})
public void readAfterCreation_nonCanonicalPreferredRegions_shouldRouteCorrectly() {
// Verify that a client built with space-stripped preferred regions (e.g., "westus3")
// routes correctly to the first preferred region via availability strategy

CosmosEndToEndOperationLatencyPolicyConfig e2ePolicy =
new CosmosEndToEndOperationLatencyPolicyConfigBuilder(Duration.ofSeconds(10))
.enable(true)
.availabilityStrategy(eagerThresholdAvailabilityStrategy)
.build();

CosmosAsyncClient clientWithNonCanonicalRegions = null;

try {
clientWithNonCanonicalRegions = new CosmosClientBuilder()
.endpoint(TestConfigurations.HOST)
.key(TestConfigurations.MASTER_KEY)
.preferredRegions(this.nonCanonicalWriteableRegions)
.multipleWriteRegionsEnabled(true)
.directMode()
.buildAsyncClient();

CosmosAsyncContainer container = clientWithNonCanonicalRegions
.getDatabase(this.testDatabaseId)
.getContainer(this.testContainerId);

ObjectNode testItem = Utils.getSimpleObjectMapper().createObjectNode();
testItem.put("id", UUID.randomUUID().toString());
testItem.put("mypk", testItem.get("id").asText());

CosmosItemResponse<ObjectNode> createResponse = container
.createItem(testItem, new PartitionKey(testItem.get("mypk").asText()), new CosmosItemRequestOptions())
.block();

assertThat(createResponse).isNotNull();
assertThat(createResponse.getStatusCode()).isEqualTo(201);

CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions();
readOptions.setCosmosEndToEndOperationLatencyPolicyConfig(e2ePolicy);

CosmosItemResponse<ObjectNode> readResponse = container
.readItem(testItem.get("id").asText(), new PartitionKey(testItem.get("mypk").asText()), readOptions, ObjectNode.class)
.block();

assertThat(readResponse).isNotNull();
assertThat(readResponse.getStatusCode()).isEqualTo(200);

CosmosDiagnosticsContext diagnosticsContext = readResponse.getDiagnostics().getDiagnosticsContext();
assertThat(diagnosticsContext).isNotNull();
assertThat(diagnosticsContext.getContactedRegionNames().size()).isGreaterThan(0);
// The first contacted region should match the first writeable region (lowercased canonical)
assertThat(diagnosticsContext.getContactedRegionNames().iterator().next())
.isEqualTo(FIRST_REGION_NAME);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Major — Testing · TreeSet.iterator().next() makes new FI test order-flaky

@copilot-pull-request-reviewer flagged this anti-pattern on PerPartitionCircuitBreakerE2ETests.java:5332 — you fixed it there in 71d9f8e. The same anti-pattern remains in FaultInjectionWithAvailabilityStrategyTestsBase.java:403-404.

This assertion uses getContactedRegionNames().iterator().next() to assert the first preferred region was contacted. But CosmosDiagnosticsContext.getContactedRegionNames() returns a TreeSet<String> (verified at CosmosDiagnosticsContext.java:428-429). iterator().next() therefore returns the alphabetically smallest region, not the first-contacted.

The test enables availabilityStrategy(eagerThresholdAvailabilityStrategy) (line ~360 of this method) — so as soon as a 2nd contacted region's name sorts before the first preferred region's, the assertion will false-fail.

The kicker: the most recent commit 71d9f8e ("Address PR review comments") explicitly fixed this same anti-pattern in PerPartitionCircuitBreakerE2ETests.java (in response to copilot-bot's comment), but did not back-port the fix to this file (added one commit earlier in a5897e9d257).

Verification:

$ grep -n "iterator().next()" FaultInjectionWithAvailabilityStrategyTestsBase.java
403:    .iterator().next()).isEqualTo(FIRST_REGION_NAME);   # only remaining occurrence
$ grep -n "FIRST_REGION_NAME" FaultInjectionWithAvailabilityStrategyTestsBase.java
2991, 3032: existing call sites already use .contains(FIRST_REGION_NAME)

Suggested fix — same pattern you used in PPCB:

assertThat(diagnosticsContext.getContactedRegionNames())
    .as("contacted regions should include the first preferred region (lowercased)")
    .contains(FIRST_REGION_NAME);

⚠️ AI-generated review — may be incorrect. Posted by router pr-review-49090. Agree → resolve the conversation. Disagree → reply with your reasoning.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

getContactedRegions is populated using TreeSet. RegionWithContext which uses time-based ordering.

} finally {
safeClose(clientWithNonCanonicalRegions);
}
}

@AfterClass(groups = { "fi-multi-master", "fi-thinclient-multi-master" })
public void afterClass() {
CosmosClientBuilder clientBuilder = new CosmosClientBuilder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5267,4 +5267,74 @@ public AccountLevelLocationContext(
this.regionNameToEndpoint = regionNameToEndpoint;
}
}

@Test(groups = {"circuit-breaker-misc-direct"}, timeOut = 4 * TIMEOUT)
public void nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly() {

if (this.writeRegions == null || this.writeRegions.size() <= 1) {
throw new SkipException("Test requires multi-region account");
}

// Build space-stripped preferred regions: "West US 3" → "westus3"
List<String> nonCanonicalRegions = new ArrayList<>();
for (String region : this.writeRegions) {
nonCanonicalRegions.add(region.toLowerCase(Locale.ROOT).replace(" ", ""));
}

CosmosClientBuilder clientBuilder = getClientBuilder()
.multipleWriteRegionsEnabled(true)
.preferredRegions(nonCanonicalRegions);

ConnectionPolicy connectionPolicy = ReflectionUtils.getConnectionPolicy(clientBuilder);
if (connectionPolicy.getConnectionMode() != ConnectionMode.DIRECT) {
throw new SkipException("Test only applicable to DIRECT mode");
}

if (Configs.isThinClientEnabled() && Configs.isHttp2Enabled()) {
throw new SkipException("DIRECT mode is not supported with thin client");
}

CosmosAsyncClient asyncClient = null;

try {
asyncClient = clientBuilder.buildAsyncClient();

CosmosAsyncContainer container = asyncClient
.getDatabase(this.sharedAsyncDatabaseId)
.getContainer(this.sharedMultiPartitionAsyncContainerIdWhereIdIsPartitionKey);

// Create an item and read it back — verify routing via diagnostics
TestObject testObject = TestObject.create();
CosmosItemResponse<TestObject> createResponse = container
.createItem(testObject, new PartitionKey(testObject.getId()), new CosmosItemRequestOptions())
.block();

assertThat(createResponse).isNotNull();
assertThat(createResponse.getStatusCode()).isEqualTo(201);

CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions();
readOptions.setCosmosEndToEndOperationLatencyPolicyConfig(NO_END_TO_END_TIMEOUT);

CosmosItemResponse<TestObject> readResponse = container
.readItem(testObject.getId(), new PartitionKey(testObject.getId()), readOptions, TestObject.class)
.block();

assertThat(readResponse).isNotNull();
assertThat(readResponse.getStatusCode()).isEqualTo(200);

CosmosDiagnosticsContext diagnosticsContext = readResponse.getDiagnostics().getDiagnosticsContext();
assertThat(diagnosticsContext).isNotNull();
assertThat(diagnosticsContext.getContactedRegionNames()).isNotEmpty();

// The contacted region should match the first preferred region (in lowercased canonical form)
String expectedFirstRegion = this.writeRegions.get(0).toLowerCase(Locale.ROOT);
assertThat(diagnosticsContext.getContactedRegionNames())
.contains(expectedFirstRegion);

} finally {
if (asyncClient != null) {
asyncClient.close();
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
package com.azure.cosmos.implementation;

import com.azure.cosmos.SessionConsistencyWithRegionScopingTests;
import com.azure.cosmos.implementation.routing.RegionNameToRegionIdMap;
import com.azure.cosmos.implementation.routing.RegionUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testng.annotations.Test;
Expand All @@ -14,30 +14,30 @@

import static org.assertj.core.api.Assertions.assertThat;

public class RegionNameToRegionIdMapTests {
public class RegionUtilsTests {

private static final Logger logger = LoggerFactory.getLogger(RegionNameToRegionIdMap.class);
private static final Logger logger = LoggerFactory.getLogger(RegionUtils.class);

@Test(groups = {"unit"})
public void regionIdToRegionNameConsistency() {

for (Map.Entry<String, Integer> sourceEntry : RegionNameToRegionIdMap.REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) {
for (Map.Entry<String, Integer> sourceEntry : RegionUtils.REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) {
String normalizedRegionNameFromSource = sourceEntry.getKey().toLowerCase(Locale.ROOT).replace(" ", "").trim();
Integer regionIdFromSource = sourceEntry.getValue();

logger.info("Testing for region : {} and region id : {}", normalizedRegionNameFromSource, regionIdFromSource);

assertThat(RegionNameToRegionIdMap.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.containsKey(normalizedRegionNameFromSource))
assertThat(RegionUtils.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.containsKey(normalizedRegionNameFromSource))
.as("NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS does not contain key : " + normalizedRegionNameFromSource)
.isTrue();

assertThat(RegionNameToRegionIdMap.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.get(normalizedRegionNameFromSource)).isEqualTo(regionIdFromSource);
assertThat(RegionUtils.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.get(normalizedRegionNameFromSource)).isEqualTo(regionIdFromSource);

assertThat(RegionNameToRegionIdMap.REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.containsKey(regionIdFromSource))
assertThat(RegionUtils.REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.containsKey(regionIdFromSource))
.as("REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS does not contain key : " + regionIdFromSource)
.isTrue();

assertThat(RegionNameToRegionIdMap.REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.get(regionIdFromSource)).isEqualTo(normalizedRegionNameFromSource);
assertThat(RegionUtils.REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.get(regionIdFromSource)).isEqualTo(normalizedRegionNameFromSource);
}
}
}
Loading
Loading