diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/ExcludeRegionTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/ExcludeRegionTests.java index 67dbc98dc0e5..1999eda0371f 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/ExcludeRegionTests.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/ExcludeRegionTests.java @@ -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 preferredRegionList; + private List nonCanonicalPreferredRegionList; private static final CosmosEndToEndOperationLatencyPolicyConfig INF_E2E_TIMEOUT = new CosmosEndToEndOperationLatencyPolicyConfigBuilder(Duration.ofDays(100)).build(); @@ -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); } @@ -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"); } @@ -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 getPreferredRegionList(CosmosAsyncClient client) { assertThat(client).isNotNull(); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java index 296a1c06b7ac..68267d960675 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java @@ -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; @@ -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; @@ -193,6 +195,7 @@ public abstract class FaultInjectionWithAvailabilityStrategyTestsBase extends Te private String SECOND_REGION_NAME = null; private List writeableRegions; + private List nonCanonicalWriteableRegions; private String testDatabaseId; private String testContainerId; @@ -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); @@ -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 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 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); + } finally { + safeClose(clientWithNonCanonicalRegions); + } + } + @AfterClass(groups = { "fi-multi-master", "fi-thinclient-multi-master" }) public void afterClass() { CosmosClientBuilder clientBuilder = new CosmosClientBuilder() diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/PerPartitionCircuitBreakerE2ETests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/PerPartitionCircuitBreakerE2ETests.java index fd856429841e..b7ed7d6ce72c 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/PerPartitionCircuitBreakerE2ETests.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/PerPartitionCircuitBreakerE2ETests.java @@ -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 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 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 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(); + } + } + } } diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionNameToRegionIdMapTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java similarity index 57% rename from sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionNameToRegionIdMapTests.java rename to sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java index 66b64fd91d4d..0cc52bc0b41c 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionNameToRegionIdMapTests.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java @@ -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; @@ -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 sourceEntry : RegionNameToRegionIdMap.REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { + for (Map.Entry 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); } } } diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/LocationCacheTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/LocationCacheTest.java index 4d0680e60318..fee1e09e44ed 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/LocationCacheTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/LocationCacheTest.java @@ -948,4 +948,151 @@ private static DatabaseAccountLocation createDatabaseAccountLocation(String name return dal; } + + // ======================================================================== + // Region name normalization integration tests + // ======================================================================== + + private static final URI WestUS3Endpoint = createUrl("https://westus3.documents.azure.com"); + private static final URI EastUSEndpoint = createUrl("https://eastus.documents.azure.com"); + private static final URI NorthEuropeEndpoint = createUrl("https://northeurope.documents.azure.com"); + + private static DatabaseAccount createDatabaseAccountWithRealRegions() { + return ModelBridgeUtils.createDatabaseAccount( + // read endpoints (server returns canonical names) + ImmutableList.of( + createDatabaseAccountLocation("West US 3", WestUS3Endpoint.toString()), + createDatabaseAccountLocation("East US", EastUSEndpoint.toString()), + createDatabaseAccountLocation("North Europe", NorthEuropeEndpoint.toString())), + // write endpoints + ImmutableList.of( + createDatabaseAccountLocation("West US 3", WestUS3Endpoint.toString()), + createDatabaseAccountLocation("East US", EastUSEndpoint.toString()), + createDatabaseAccountLocation("North Europe", NorthEuropeEndpoint.toString())), + true); + } + + private LocationCache createCacheWithRealRegions(List preferredRegions) { + ConnectionPolicy connectionPolicy = new ConnectionPolicy(DirectConnectionConfig.getDefaultConfig()); + connectionPolicy.setEndpointDiscoveryEnabled(true); + connectionPolicy.setMultipleWriteRegionsEnabled(true); + connectionPolicy.setPreferredRegions(preferredRegions); + + LocationCache locationCache = new LocationCache( + connectionPolicy, + DefaultEndpoint, + configs); + + locationCache.onDatabaseAccountRead(createDatabaseAccountWithRealRegions()); + return locationCache; + } + + @Test(groups = "unit") + public void preferredRegions_lowercaseShouldMatchCanonical() { + // Customer passes "west us 3" (all lowercase) instead of "West US 3" + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("west us 3", "east us", "north europe")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(NorthEuropeEndpoint); + } + + @Test(groups = "unit") + public void preferredRegions_noSpacesShouldMatchCanonical() { + // Customer passes "westus3" (no spaces) instead of "West US 3" + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("westus3", "eastus", "northeurope")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(NorthEuropeEndpoint); + } + + @Test(groups = "unit") + public void preferredRegions_uppercaseShouldMatchCanonical() { + // Customer passes "WEST US 3" (all uppercase) + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("WEST US 3", "EAST US", "NORTH EUROPE")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(NorthEuropeEndpoint); + } + + @Test(groups = "unit") + public void excludeRegions_lowercaseNoSpacesShouldExclude() { + // Preferred regions in canonical form, exclude region with no spaces + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + + AtomicReference excludedRef = new AtomicReference<>( + new CosmosExcludedRegions(new HashSet<>(Arrays.asList("westus3")))); + + ConnectionPolicy connectionPolicy = ReflectionUtils.getConnectionPolicy(locationCache); + connectionPolicy.setExcludedRegionsSupplier(excludedRef::get); + + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + + List applicableEndpoints = + locationCache.getApplicableReadRegionRoutingContexts(request); + + // "westus3" should exclude "West US 3" + assertThat(applicableEndpoints.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(WestUS3Endpoint); + assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + } + + @Test(groups = "unit") + public void excludeRegions_mixedCasingShouldExclude() { + // Exclude with weird casing: "EAST us", "north EUROPE" + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + + AtomicReference excludedRef = new AtomicReference<>( + new CosmosExcludedRegions(new HashSet<>(Arrays.asList("EAST us", "north EUROPE")))); + + ConnectionPolicy connectionPolicy = ReflectionUtils.getConnectionPolicy(locationCache); + connectionPolicy.setExcludedRegionsSupplier(excludedRef::get); + + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + + List applicableEndpoints = + locationCache.getApplicableReadRegionRoutingContexts(request); + + // Only West US 3 should remain + assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(applicableEndpoints.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(EastUSEndpoint) + .doesNotContain(NorthEuropeEndpoint); + } + + @Test(groups = "unit") + public void excludeRegions_requestLevelNoSpacesShouldExclude() { + // Request-level exclude with space-stripped names + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + request.requestContext.setExcludeRegions(Arrays.asList("eastus")); + + List applicableEndpoints = + locationCache.getApplicableReadRegionRoutingContexts(request); + + assertThat(applicableEndpoints.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(EastUSEndpoint); + assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + } } diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsNormalizationTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsNormalizationTest.java new file mode 100644 index 000000000000..addd68b2b8d8 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsNormalizationTest.java @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.cosmos.implementation.routing; + +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link RegionUtils} + */ +public class RegionUtilsNormalizationTest { + + @DataProvider(name = "regionNameVariants") + public Object[][] regionNameVariants() { + return new Object[][] { + // { input, expected canonical output } + + // Case normalization + { "west us 3", "West US 3" }, + { "WEST US 3", "West US 3" }, + { "West Us 3", "West US 3" }, + { "wEsT uS 3", "West US 3" }, + + // Space-stripped variants (no spaces) + { "westus3", "West US 3" }, + { "WestUS3", "West US 3" }, + { "WESTUS3", "West US 3" }, + + // Already canonical (no-op) + { "West US 3", "West US 3" }, + { "East US", "East US" }, + { "North Europe", "North Europe" }, + { "Central India", "Central India" }, + + // Various regions + { "east us 2", "East US 2" }, + { "eastus2", "East US 2" }, + { "southcentralus", "South Central US" }, + { "south central us", "South Central US" }, + { "australiaeast", "Australia East" }, + { "australia east", "Australia East" }, + { "uksouth", "UK South" }, + { "uk south", "UK South" }, + { "northeurope", "North Europe" }, + { "westeurope", "West Europe" }, + { "brazilsouth", "Brazil South" }, + { "japaneast", "Japan East" }, + { "koreacentral", "Korea Central" }, + { "centraluseuap", "Central US EUAP" }, + { "eastus2euap", "East US 2 EUAP" }, + { "switzerlandnorth", "Switzerland North" }, + { "swedencentral", "Sweden Central" }, + { "qatarcentral", "Qatar Central" }, + { "italynorth", "Italy North" }, + + // Government regions + { "usgovvirginia", "USGov Virginia" }, + { "usgovarizona", "USGov Arizona" }, + { "usdodcentral", "USDoD Central" }, + { "usseceast", "USSec East" }, + { "usnateast", "USNat East" }, + + // China regions + { "chinaeast2", "China East 2" }, + { "chinanorth3", "China North 3" }, + + // Newer regions + { "mexicocentral", "Mexico Central" }, + { "israelcentral", "Israel Central" }, + { "newzealandnorth", "New Zealand North" }, + }; + } + + @Test(groups = "unit", dataProvider = "regionNameVariants") + public void shouldNormalizeRegionNameVariants(String input, String expectedCanonical) { + String result = RegionUtils.getCosmosDBRegionName(input); + assertThat(result).isEqualTo(expectedCanonical); + } + + @Test(groups = "unit") + public void shouldNormalizeUnknownRegions() { + // Unknown regions should be returned in normalized form (lowercase, no spaces) + assertThat(RegionUtils.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("mycustomregion"); + assertThat(RegionUtils.getCosmosDBRegionName("FutureRegion42")).isEqualTo("futureregion42"); + } + + @Test(groups = "unit") + public void shouldHandleNullAndEmpty() { + assertThat(RegionUtils.getCosmosDBRegionName(null)).isNull(); + assertThat(RegionUtils.getCosmosDBRegionName("")).isEqualTo(""); + } + + @Test(groups = "unit") + public void shouldHandleBlankString() { + // Blank strings (only spaces) → stripped to "" → normalized to "" + assertThat(RegionUtils.getCosmosDBRegionName(" ")).isEqualTo(""); + } + + @Test(groups = "unit") + public void unknownRegionVariantsShouldCollapse() { + // Unknown regions: different variants should collapse to the same normalized form + assertThat(RegionUtils.getCosmosDBRegionName("futureregion99")).isEqualTo("futureregion99"); + assertThat(RegionUtils.getCosmosDBRegionName("Future Region 99")).isEqualTo("futureregion99"); + assertThat(RegionUtils.getCosmosDBRegionName("FUTURE REGION 99")).isEqualTo("futureregion99"); + } + + // ======================================================================== + // normalizeRegionNames tests + // ======================================================================== + + @Test(groups = "unit") + public void normalizeRegionNames_shouldNormalizeList() { + assertThat(RegionUtils.normalizeRegionNames(Arrays.asList("westus3", "east us"))) + .containsExactly("West US 3", "East US"); + } + + @Test(groups = "unit") + public void normalizeRegionNames_shouldHandleNullAndEmpty() { + assertThat(RegionUtils.normalizeRegionNames(null)).isEmpty(); + assertThat(RegionUtils.normalizeRegionNames(Collections.emptyList())).isEmpty(); + } + + @Test(groups = "unit") + public void normalizeRegionNames_shouldDropNullElements() { + assertThat(RegionUtils.normalizeRegionNames(Arrays.asList("East US", null, "westus3"))) + .containsExactly("East US", "West US 3"); + } + + // ======================================================================== + // containsRegionIgnoreCase tests + // ======================================================================== + + @Test(groups = "unit") + public void containsRegionIgnoreCase_shouldMatchNormalized() { + assertThat(RegionUtils.containsRegionIgnoreCase(Arrays.asList("westus3"), "West US 3")).isTrue(); + assertThat(RegionUtils.containsRegionIgnoreCase(Arrays.asList("West US 3"), "WEST US 3")).isTrue(); + assertThat(RegionUtils.containsRegionIgnoreCase(Arrays.asList("West US 3"), "westus3")).isTrue(); + } + + @Test(groups = "unit") + public void containsRegionIgnoreCase_shouldReturnFalseForNonMatch() { + assertThat(RegionUtils.containsRegionIgnoreCase(Arrays.asList("East US"), "West US 3")).isFalse(); + } + + @Test(groups = "unit") + public void containsRegionIgnoreCase_shouldHandleNullAndEmpty() { + assertThat(RegionUtils.containsRegionIgnoreCase(null, "anything")).isFalse(); + assertThat(RegionUtils.containsRegionIgnoreCase(Collections.emptyList(), "anything")).isFalse(); + } + + @Test(groups = "unit") + public void containsRegionIgnoreCase_shouldHandleNullElements() { + assertThat(RegionUtils.containsRegionIgnoreCase(Arrays.asList("East US", null), "East US")).isTrue(); + assertThat(RegionUtils.containsRegionIgnoreCase(Arrays.asList(null, null), "East US")).isFalse(); + } +} diff --git a/sdk/cosmos/azure-cosmos/CHANGELOG.md b/sdk/cosmos/azure-cosmos/CHANGELOG.md index 6eb1b76bad4e..a32ea098f168 100644 --- a/sdk/cosmos/azure-cosmos/CHANGELOG.md +++ b/sdk/cosmos/azure-cosmos/CHANGELOG.md @@ -7,6 +7,7 @@ #### Breaking Changes #### Bugs Fixed +* Fixed region name normalization: preferred regions and excluded regions passed in non-canonical forms (e.g., `"westus3"`, `"west us 3"`, `"WEST US 3"` instead of `"West US 3"`) are now normalized to the canonical CosmosDB format. Also fixed a case-sensitive `List.contains()` bug in the per-partition circuit breaker reevaluate logic that could cause excluded regions to be re-added as retry targets. - See [PR 49090](https://github.com/Azure/azure-sdk-for-java/pull/49090) #### Other Changes * Replaced per-client `Schedulers.newSingle()` schedulers in `GlobalEndpointManager` and `GlobalPartitionEndpointManagerForPerPartitionCircuitBreaker` with shared `BoundedElastic` schedulers in `CosmosSchedulers` to prevent thread count from scaling linearly with client/tenant count. - See [PR 49062](https://github.com/Azure/azure-sdk-for-java/pull/49062) diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ConnectionPolicy.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ConnectionPolicy.java index b93171e3bdcd..986fc5b3df2a 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ConnectionPolicy.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ConnectionPolicy.java @@ -12,7 +12,10 @@ import com.azure.cosmos.Http2ConnectionConfig; import com.azure.cosmos.ThrottlingRetryOptions; +import com.azure.cosmos.implementation.routing.RegionUtils; + import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -484,7 +487,20 @@ public List getPreferredRegions() { * @return the ConnectionPolicy. */ public ConnectionPolicy setPreferredRegions(List preferredRegions) { - this.preferredRegions = preferredRegions; + if (preferredRegions == null || preferredRegions.isEmpty()) { + this.preferredRegions = preferredRegions; + return this; + } + + // Normalize each region to canonical CosmosDB form (e.g., "westus3" → "West US 3"). + // Unknown regions not in the static map are passed through as-is. + List normalized = new ArrayList<>(preferredRegions.size()); + for (String region : preferredRegions) { + if (region != null) { + normalized.add(RegionUtils.getCosmosDBRegionName(region)); + } + } + this.preferredRegions = normalized; return this; } diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionScopedRegionLevelProgress.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionScopedRegionLevelProgress.java index c88955c78ea4..d19e2b88abb3 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionScopedRegionLevelProgress.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionScopedRegionLevelProgress.java @@ -8,7 +8,7 @@ import com.azure.cosmos.implementation.apachecommons.collections.map.UnmodifiableMap; import com.azure.cosmos.implementation.apachecommons.lang.StringUtils; import com.azure.cosmos.implementation.routing.PartitionKeyInternal; -import com.azure.cosmos.implementation.routing.RegionNameToRegionIdMap; +import com.azure.cosmos.implementation.routing.RegionUtils; import com.azure.cosmos.models.PartitionKeyDefinition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -126,7 +126,7 @@ public void tryRecordSessionToken( String normalizedRegionRoutedTo = this.normalizedRegionLookupMap.get(regionRoutedTo); - int regionId = RegionNameToRegionIdMap.getRegionId(normalizedRegionRoutedTo); + int regionId = RegionUtils.getRegionId(normalizedRegionRoutedTo); if (regionId != -1) { long localLsn = localLsnByRegion.v.getOrDefault(regionId, Long.MIN_VALUE); @@ -355,7 +355,7 @@ public ISessionToken tryResolveSessionToken( for (Map.Entry localLsnByRegionEntry : localLsnByRegion.v.entrySet()) { int regionId = localLsnByRegionEntry.getKey(); - String normalizedRegionName = RegionNameToRegionIdMap.getRegionName(regionId); + String normalizedRegionName = RegionUtils.getRegionName(regionId); // the regionId to normalizedRegionName does not exist if (normalizedRegionName.equals(StringUtils.EMPTY)) { @@ -401,7 +401,7 @@ public ISessionToken tryResolveSessionToken( // Obtain globalLsn from hub region for (String lesserPreferredRegionPkProbablyRequestedFrom : lesserPreferredRegionsPkProbablyRequestedFrom) { - int regionId = RegionNameToRegionIdMap.getRegionId(lesserPreferredRegionPkProbablyRequestedFrom); + int regionId = RegionUtils.getRegionId(lesserPreferredRegionPkProbablyRequestedFrom); boolean isHubRegion = !localLsnByRegion.v.containsKey(regionId); if (isHubRegion) { diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java index 59d15b94457e..9a66f048b725 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentClientImpl.java @@ -68,7 +68,7 @@ import com.azure.cosmos.implementation.routing.PartitionKeyInternalHelper; import com.azure.cosmos.implementation.routing.PartitionKeyRangeIdentity; import com.azure.cosmos.implementation.routing.Range; -import com.azure.cosmos.implementation.routing.RegionNameToRegionIdMap; +import com.azure.cosmos.implementation.routing.RegionUtils; import com.azure.cosmos.implementation.routing.RegionalRoutingContext; import com.azure.cosmos.implementation.spark.OperationContext; import com.azure.cosmos.implementation.spark.OperationContextAndListenerTuple; @@ -835,7 +835,7 @@ private boolean isRegionScopingOfSessionTokensPossible(DatabaseAccount databaseA String normalizedReadableRegion = readableLocation.getName().toLowerCase(Locale.ROOT).trim().replace(" ", ""); - if (RegionNameToRegionIdMap.getRegionId(normalizedReadableRegion) == -1) { + if (RegionUtils.getRegionId(normalizedReadableRegion) == -1) { return false; } } diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationCache.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationCache.java index a15fd02c68c7..c613cf77935b 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationCache.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationCache.java @@ -345,11 +345,15 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); + // Normalize user-configured exclude regions to canonical form for consistent comparison. + // Unknown regions not in the static map are passed through as-is. + List normalizedUserExcludeRegions = RegionUtils.normalizeRegionNames(userConfiguredExcludeRegions); + // exclude those regions which are user excluded first for (RegionalRoutingContext endpoint : regionalRoutingContexts) { Utils.ValueHolder regionName = new Utils.ValueHolder<>(); if (Utils.tryGetValue(regionNameByRegionalRoutingContext, endpoint, regionName)) { - if (!userConfiguredExcludeRegions.stream().anyMatch(regionName.v::equalsIgnoreCase)) { + if (!normalizedUserExcludeRegions.stream().anyMatch(regionName.v::equalsIgnoreCase)) { applicableEndpoints.add(endpoint); } } @@ -392,7 +396,7 @@ private UnmodifiableList getApplicableRegionRoutingConte new UnmodifiableList<>(applicableEndpoints), regionNameByRegionalRoutingContext, regionalRoutingContextByRegionName, - userConfiguredExcludeRegions, + normalizedUserExcludeRegions, endpointsRemovedByInternalExcludeRegions, internalExcludeRegions, regionalRoutingContexts, @@ -499,7 +503,7 @@ private UnmodifiableList reevaluate( Utils.ValueHolder regionalRoutingContextValueHolder = new Utils.ValueHolder<>(null); if (Utils.tryGetValue(regionalRoutingContextsByRegionName, internalExcludeRegion, regionalRoutingContextValueHolder)) { - if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) && !userConfiguredExcludeRegions.contains(internalExcludeRegion)) { + if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) && !RegionUtils.containsRegionIgnoreCase(userConfiguredExcludeRegions, internalExcludeRegion)) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -1066,7 +1070,7 @@ static class DatabaseAccountLocationsInfo { public DatabaseAccountLocationsInfo(List preferredLocations, RegionalRoutingContext defaultRoutingContext) { - this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> loc.toLowerCase(Locale.ROOT)).collect(Collectors.toList())); + this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> RegionUtils.getCosmosDBRegionName(loc).toLowerCase(Locale.ROOT)).collect(Collectors.toList())); this.effectivePreferredLocations = new UnmodifiableList<>(Collections.emptyList()); this.availableWriteRegionalRoutingContextsByRegionName = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(new CaseInsensitiveMap<>()); diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMap.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMap.java deleted file mode 100644 index cfd522641888..000000000000 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMap.java +++ /dev/null @@ -1,262 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.cosmos.implementation.routing; - -import com.azure.cosmos.implementation.apachecommons.lang.StringUtils; - -import java.util.HashMap; -import java.util.Map; - -/** - * ATTENTION: Please ensure the below map is consistent with RegionToIdMap.cs to avoid breaking behavior. - *

- * The purpose of the below map is to track region-specific progress from the session token (localLsn). If we know - * the region name a request was routed to - the below map will help us obtain the localLsn for that region and partition combination - * */ -public class RegionNameToRegionIdMap { - public static final Map REGION_NAME_TO_REGION_ID_MAPPINGS = new HashMap() { - { - put("East US", 1); - put("East US 2", 2); - put("Central US", 3); - put("North Central US", 4); - put("South Central US", 5); - put("West Central US", 6); - put("West US", 7); - put("West US 2", 8); - put("Canada East", 9); - put("Canada Central", 10); - put("Brazil South", 11); - put("North Europe", 12); - put("West Europe", 13); - put("France Central", 14); - put("France South", 15); - put("UK West", 16); - put("UK South", 17); - put("Germany Central", 18); - put("Germany Northeast", 19); - put("Germany North", 20); - put("Germany West Central", 21); - put("Switzerland North", 22); - put("Switzerland West", 23); - put("Southeast Asia", 24); - put("East Asia", 25); - put("Australia East", 26); - put("Australia Southeast", 27); - put("Australia Central", 28); - put("Australia Central 2", 29); - put("China East", 30); - put("China North", 31); - put("Central India", 32); - put("West India", 33); - put("South India", 34); - put("Japan East", 35); - put("Japan West", 36); - put("Korea Central", 37); - put("Korea South", 38); - put("USGov Virginia", 39); - put("USGov Iowa", 40); - put("USGov Arizona", 41); - put("USGov Texas", 42); - put("USDoD East", 43); - put("USDoD Central", 44); - put("USSec East", 45); - put("USSec West", 46); - put("South Africa West", 47); - put("South Africa North", 48); - put("UAE Central", 49); - put("UAE North", 50); - put("Central US EUAP", 51); - put("East US 2 EUAP", 52); - put("North Europe 2", 53); - put("easteurope", 54); - put("APAC Southeast 2", 55); - put("UK South 2", 56); - put("UK North", 57); - put("East US STG", 58); - put("South Central US STG", 59); - put("Norway East", 60); - put("Norway West", 61); - put("USGov Wyoming", 62); - put("USDoD Southwest", 63); - put("USDoD West Central", 64); - put("USDoD South Central", 65); - put("China East 2", 66); - put("China North 2", 67); - put("USNat East", 68); - put("USNat West", 69); - put("China North 10", 70); - put("Sweden Central", 71); - put("Sweden South", 72); - put("Korea South 2", 73); - put("USSec West Central", 113); - } - }; - - public static final Map REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS = new HashMap() { - { - put(49, "uaecentral"); - put(14, "francecentral"); - put(65, "usdodsouthcentral"); - put(26, "australiaeast"); - put(27, "australiasoutheast"); - put(16, "ukwest"); - put(40, "usgoviowa"); - put(72, "swedensouth"); - put(69, "usnatwest"); - put(13, "westeurope"); - put(50, "uaenorth"); - put(53, "northeurope2"); - put(36, "japanwest"); - put(5, "southcentralus"); - put(37, "koreacentral"); - put(60, "norwayeast"); - put(11, "brazilsouth"); - put(29, "australiacentral2"); - put(28, "australiacentral"); - put(73, "koreasouth2"); - put(32, "centralindia"); - put(35, "japaneast"); - put(45, "usseceast"); - put(25, "eastasia"); - put(6, "westcentralus"); - put(19, "germanynortheast"); - put(23, "switzerlandwest"); - put(52, "eastus2euap"); - put(8, "westus2"); - put(43, "usdodeast"); - put(17, "uksouth"); - put(56, "uksouth2"); - put(10, "canadacentral"); - put(68, "usnateast"); - put(20, "germanynorth"); - put(9, "canadaeast"); - put(67, "chinanorth2"); - put(22, "switzerlandnorth"); - put(58, "eastusstg"); - put(1, "eastus"); - put(57, "uknorth"); - put(4, "northcentralus"); - put(54, "easteurope"); - put(42, "usgovtexas"); - put(61, "norwaywest"); - put(55, "apacsoutheast2"); - put(12, "northeurope"); - put(59, "southcentralusstg"); - put(21, "germanywestcentral"); - put(24, "southeastasia"); - put(71, "swedencentral"); - put(31, "chinanorth"); - put(62, "usgovwyoming"); - put(30, "chinaeast"); - put(2, "eastus2"); - put(34, "southindia"); - put(51, "centraluseuap"); - put(18, "germanycentral"); - put(7, "westus"); - put(44, "usdodcentral"); - put(66, "chinaeast2"); - put(39, "usgovvirginia"); - put(64, "usdodwestcentral"); - put(70, "chinanorth10"); - put(41, "usgovarizona"); - put(33, "westindia"); - put(38, "koreasouth"); - put(3, "centralus"); - put(63, "usdodsouthwest"); - put(47, "southafricawest"); - put(46, "ussecwest"); - put(15, "francesouth"); - put(48, "southafricanorth"); - put(113, "ussecwestcentral"); - } - }; - - public static final Map NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS = new HashMap() { - { - put("southafricanorth", 48); - put("westus2", 8); - put("australiacentral", 28); - put("apacsoutheast2", 55); - put("eastasia", 25); - put("uknorth", 57); - put("francecentral", 14); - put("southafricawest", 47); - put("usgovtexas", 42); - put("koreacentral", 37); - put("centralus", 3); - put("japaneast", 35); - put("westeurope", 13); - put("norwayeast", 60); - put("eastus", 1); - put("australiasoutheast", 27); - put("centralindia", 32); - put("usdodeast", 43); - put("germanycentral", 18); - put("usdodwestcentral", 64); - put("switzerlandwest", 23); - put("chinaeast2", 66); - put("westus", 7); - put("northcentralus", 4); - put("usdodcentral", 44); - put("uaenorth", 50); - put("centraluseuap", 51); - put("germanywestcentral", 21); - put("ussecwest", 46); - put("usnateast", 68); - put("uksouth", 17); - put("usgovvirginia", 39); - put("usgoviowa", 40); - put("chinanorth2", 67); - put("germanynorth", 20); - put("easteurope", 54); - put("uksouth2", 56); - put("ukwest", 16); - put("japanwest", 36); - put("usdodsouthcentral", 65); - put("australiaeast", 26); - put("westindia", 33); - put("australiacentral2", 29); - put("southindia", 34); - put("eastus2euap", 52); - put("canadaeast", 9); - put("southeastasia", 24); - put("koreasouth", 38); - put("southcentralus", 5); - put("eastusstg", 58); - put("chinanorth10", 70); - put("swedensouth", 72); - put("westcentralus", 6); - put("eastus2", 2); - put("chinaeast", 30); - put("usgovarizona", 41); - put("norwaywest", 61); - put("uaecentral", 49); - put("swedencentral", 71); - put("usdodsouthwest", 63); - put("usnatwest", 69); - put("chinanorth", 31); - put("northeurope2", 53); - put("usgovwyoming", 62); - put("brazilsouth", 11); - put("koreasouth2", 73); - put("canadacentral", 10); - put("southcentralusstg", 59); - put("usseceast", 45); - put("francesouth", 15); - put("germanynortheast", 19); - put("switzerlandnorth", 22); - put("northeurope", 12); - put("ussecwestcentral", 113); - } - }; - - public static String getRegionName(int regionId) { - return REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.getOrDefault(regionId, StringUtils.EMPTY); - } - - public static int getRegionId(String regionName) { - return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(regionName, -1); - } -} diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionUtils.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionUtils.java new file mode 100644 index 000000000000..6aa5363473ec --- /dev/null +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionUtils.java @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.cosmos.implementation.routing; + +import com.azure.cosmos.implementation.apachecommons.lang.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Single source of truth for Azure region name mappings in the Cosmos Java SDK. + *

+ * Provides two capabilities: + *

    + *
  1. Region ID mapping — canonical name ↔ numeric ID for session token region-level progress tracking. + * Must stay in sync with the authoritative regionToIdMapping in + * Settings.xml.
  2. + *
  3. Region name normalization — maps any user-supplied variant ("westus3", "west us 3", "WEST US 3") + * to the canonical CosmosDB format ("West US 3"). Unknown regions not in the static map are + * returned as-is.
  4. + *
+ */ +public class RegionUtils { + + // ======================================================================== + // Region ID mappings — used only for session token region-level progress + // tracking (localLsn). Must stay in sync with the authoritative + // regionToIdMapping in Settings.xml: + // https://msdata.visualstudio.com/CosmosDB/_git/CosmosDB?path=/Product/Services/Documents/ImageStore/Storage/SingleServiceMasterServerApplication/ServerServicePackage/Settings.xml + // This is a SUBSET of all known regions — only regions with assigned IDs. + // ======================================================================== + + public static final Map REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(new HashMap() { + { + put("East US", 1); + put("East US 2", 2); + put("Central US", 3); + put("North Central US", 4); + put("South Central US", 5); + put("West Central US", 6); + put("West US", 7); + put("West US 2", 8); + put("Canada East", 9); + put("Canada Central", 10); + put("Brazil South", 11); + put("North Europe", 12); + put("West Europe", 13); + put("France Central", 14); + put("France South", 15); + put("UK West", 16); + put("UK South", 17); + put("Germany Central", 18); + put("Germany Northeast", 19); + put("Germany North", 20); + put("Germany West Central", 21); + put("Switzerland North", 22); + put("Switzerland West", 23); + put("Southeast Asia", 24); + put("East Asia", 25); + put("Australia East", 26); + put("Australia Southeast", 27); + put("Australia Central", 28); + put("Australia Central 2", 29); + put("China East", 30); + put("China North", 31); + put("Central India", 32); + put("West India", 33); + put("South India", 34); + put("Japan East", 35); + put("Japan West", 36); + put("Korea Central", 37); + put("Korea South", 38); + put("USGov Virginia", 39); + put("USGov Iowa", 40); + put("USGov Arizona", 41); + put("USGov Texas", 42); + put("USDoD East", 43); + put("USDoD Central", 44); + put("USSec East", 45); + put("USSec West", 46); + put("South Africa West", 47); + put("South Africa North", 48); + put("UAE Central", 49); + put("UAE North", 50); + put("Central US EUAP", 51); + put("East US 2 EUAP", 52); + put("North Europe 2", 53); + put("East Europe", 54); + put("APAC Southeast 2", 55); + put("UK South 2", 56); + put("UK North", 57); + put("East US STG", 58); + put("South Central US STG", 59); + put("Norway East", 60); + put("Norway West", 61); + put("USGov Wyoming", 62); + put("USDoD Southwest", 63); + put("USDoD West Central", 64); + put("USDoD South Central", 65); + put("China East 2", 66); + put("China North 2", 67); + put("USNat East", 68); + put("USNat West", 69); + put("China North 10", 70); + put("Sweden Central", 71); + put("Sweden South", 72); + put("Korea South 2", 73); + put("Brazil Southeast", 74); + put("Brazil Northeast", 75); + put("Chile Central", 76); + put("West US 3", 77); + put("Jio India West", 78); + put("Jio India Central", 79); + put("Qatar Central", 80); + put("Israel Central", 81); + put("Mexico Central", 82); + put("Spain Central", 83); + put("Taiwan North", 84); + put("Singapore Gov", 85); + put("Poland Central", 86); + put("Chile North Central", 87); + put("USSec Central", 88); + put("Malaysia West", 89); + put("New Zealand North", 90); + put("Italy North", 91); + put("East US SLV", 92); + put("China North 3", 93); + put("China East 3", 94); + put("Austria East", 95); + put("Taiwan Northwest", 96); + put("Belgium Central", 97); + put("Malaysia South", 98); + put("India South Central", 99); + put("Indonesia Central", 100); + put("Finland Central", 101); + put("Israel Northwest", 102); + put("Denmark East", 103); + put("Southeast US", 104); + put("Ocave", 105); + put("Arlem", 106); + put("Bleu France Central", 107); + put("Bleu France South", 108); + put("Delos Cloud Germany Central", 109); + put("Delos Cloud Germany North", 110); + put("Singapore Central", 111); + put("Singapore North", 112); + put("USSec West Central", 113); + put("South Central US 2", 114); + put("Southwest US", 115); + put("East US 3", 116); + put("Southeast US 3", 117); + put("USNat North", 118); + put("Southeast US 5", 119); + put("Saudi Arabia East", 120); + put("West Central US FRE", 121); + put("Northeast US 5", 122); + put("Southeast Asia 3", 123); + put("North Europe 3", 124); + } + }); + + public static final Map REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS; + + public static final Map NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS; + + static { + // Derive both maps programmatically from REGION_NAME_TO_REGION_ID_MAPPINGS + Map idToNormalized = new HashMap<>(); + Map normalizedToId = new HashMap<>(); + for (Map.Entry entry : REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { + String normalized = entry.getKey().toLowerCase(Locale.ROOT).replace(" ", ""); + normalizedToId.put(normalized, entry.getValue()); + idToNormalized.putIfAbsent(entry.getValue(), normalized); + } + NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(normalizedToId); + REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS = Collections.unmodifiableMap(idToNormalized); + } + + public static String getRegionName(int regionId) { + return REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.getOrDefault(regionId, StringUtils.EMPTY); + } + + public static int getRegionId(String regionName) { + return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(regionName, -1); + } + + // ======================================================================== + // Region name normalization — canonical names derived from the ID map + // (sourced from Settings.xml regionToIdMapping). Used for normalizing + // user-supplied preferred regions and excluded regions to the canonical + // CosmosDB format. Unknown regions not in this map are passed through as-is. + // ======================================================================== + + // Static map: lowercase-no-spaces key → canonical display name + private static final Map NORMALIZED_TO_CANONICAL; + + static { + Map map = new HashMap<>(); + + // Seed from the ID map — Settings.xml is the authoritative source for all + // canonical region names. Every region with an assigned ID is automatically + // included in the normalization map. + for (String canonicalName : REGION_NAME_TO_REGION_ID_MAPPINGS.keySet()) { + addCanonicalMapping(map, canonicalName); + } + + NORMALIZED_TO_CANONICAL = Collections.unmodifiableMap(map); + } + + /** + * Normalizes a region name to the canonical CosmosDB format. + *

+ * Strips spaces, lowercases, and looks up in the static known-region map. + * If recognized, returns the canonical form (e.g., "West US 3"). + * If not recognized, returns the normalized form (lowercase, no spaces) + * for forward compatibility — this ensures unknown regions still match + * after LocationCache applies toLowerCase() to server-returned names. + * + * @param regionName the region name to normalize (any casing/spacing variant) + * @return the canonical CosmosDB region name, or the lowercase space-stripped + * form if unrecognized + */ + public static String getCosmosDBRegionName(String regionName) { + if (StringUtils.isEmpty(regionName)) { + return regionName; + } + + String normalized = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + + String canonical = NORMALIZED_TO_CANONICAL.get(normalized); + if (canonical != null) { + return canonical; + } + + return normalized; + } + + /** + * Normalizes a list of region names to canonical CosmosDB format. + * Unknown regions not in the static map are passed through as-is. + * + * @param regionNames the list of region names to normalize + * @return a new list with each region normalized + */ + public static List normalizeRegionNames(List regionNames) { + if (regionNames == null || regionNames.isEmpty()) { + return Collections.emptyList(); + } + List normalized = new ArrayList<>(regionNames.size()); + for (String region : regionNames) { + if (region != null) { + normalized.add(getCosmosDBRegionName(region)); + } + } + return normalized; + } + + /** + * Checks whether a list of region names contains the target region, + * using canonical normalization + case-insensitive comparison. + * + * @param regions the list of region names to search + * @param target the target region name to find + * @return true if any region in the list matches the target after normalization + */ + public static boolean containsRegionIgnoreCase(List regions, String target) { + if (regions == null || regions.isEmpty()) { + return false; + } + String normalizedTarget = getCosmosDBRegionName(target); + for (String region : regions) { + if (region != null && getCosmosDBRegionName(region).equalsIgnoreCase(normalizedTarget)) { + return true; + } + } + return false; + } + + private static void addCanonicalMapping(Map map, String canonicalName) { + String key = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); + map.putIfAbsent(key, canonicalName); + } +}