From cbde001cd714904e58e7f3654199b7e24b1b42c9 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 09:58:43 -0400 Subject: [PATCH 01/14] Port RegionNameMapper from .NET SDK to normalize region names Customers passing region names in non-canonical forms (e.g., 'west us 3' instead of 'West US 3') hit routing issues because the Java SDK stores region names in different forms and some comparisons use case-sensitive String.equals()/List.contains(). Changes: - Add RegionNameMapper: strips spaces + case-insensitive lookup against 90+ known Azure regions to produce canonical names (e.g., 'westus3' or 'west us 3' -> 'West US 3'). Unknown regions pass through as-is. - ConnectionPolicy.setPreferredRegions(): normalize + order-preserving dedupe at entry point. - LocationCache constructor: apply RegionNameMapper before toLowerCase for defense-in-depth. - Fix case-sensitive List.contains() bug in reevaluate() (line 502): use containsRegionIgnoreCase() instead. - Normalize user-configured exclude regions at point of use in getApplicableRegionRoutingContexts() to prevent mismatches with PPCB-derived lowercased region names. - Add RegionNameMapperTest with 43 unit tests covering case variants, space removal, passthrough, null/empty handling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../routing/RegionNameMapperTest.java | 101 +++++++++++ .../implementation/ConnectionPolicy.java | 18 +- .../implementation/routing/LocationCache.java | 37 +++- .../routing/RegionNameMapper.java | 159 ++++++++++++++++++ 4 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java create mode 100644 sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java new file mode 100644 index 000000000000..19984f36a0f6 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java @@ -0,0 +1,101 @@ +// 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 static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link RegionNameMapper} + */ +public class RegionNameMapperTest { + + @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 = RegionNameMapper.getCosmosDBRegionName(input); + assertThat(result).isEqualTo(expectedCanonical); + } + + @Test(groups = "unit") + public void shouldPassthroughUnknownRegions() { + // Unknown regions should be returned as-is for forward compatibility + assertThat(RegionNameMapper.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); + assertThat(RegionNameMapper.getCosmosDBRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); + } + + @Test(groups = "unit") + public void shouldHandleNullAndEmpty() { + assertThat(RegionNameMapper.getCosmosDBRegionName(null)).isNull(); + assertThat(RegionNameMapper.getCosmosDBRegionName("")).isEqualTo(""); + } + + @Test(groups = "unit") + public void shouldHandleBlankString() { + // Blank strings (only spaces) → stripped to "" → not in map → returned as-is + assertThat(RegionNameMapper.getCosmosDBRegionName(" ")).isEqualTo(" "); + } +} 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..b71e77e9bf57 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,8 +12,12 @@ import com.azure.cosmos.Http2ConnectionConfig; import com.azure.cosmos.ThrottlingRetryOptions; +import com.azure.cosmos.implementation.routing.RegionNameMapper; + import java.time.Duration; +import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.function.Supplier; @@ -484,7 +488,19 @@ 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 form and dedupe (order-preserving) + LinkedHashSet deduped = new LinkedHashSet<>(); + for (String region : preferredRegions) { + if (region != null) { + deduped.add(RegionNameMapper.getCosmosDBRegionName(region)); + } + } + this.preferredRegions = new ArrayList<>(deduped); return this; } 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..c59c536761e4 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,14 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); + // Normalize user-configured exclude regions to canonical form for consistent comparison + List normalizedUserExcludeRegions = 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 +395,7 @@ private UnmodifiableList getApplicableRegionRoutingConte new UnmodifiableList<>(applicableEndpoints), regionNameByRegionalRoutingContext, regionalRoutingContextByRegionName, - userConfiguredExcludeRegions, + normalizedUserExcludeRegions, endpointsRemovedByInternalExcludeRegions, internalExcludeRegions, regionalRoutingContexts, @@ -499,7 +502,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) && !containsRegionIgnoreCase(userConfiguredExcludeRegions, internalExcludeRegion)) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -1047,6 +1050,32 @@ private static boolean isExcludedRegionsSupplierConfigured(Supplier 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(RegionNameMapper.getCosmosDBRegionName(region)); + } + } + return normalized; + } + + private static boolean containsRegionIgnoreCase(List regions, String target) { + if (regions == null || regions.isEmpty()) { + return false; + } + String normalizedTarget = RegionNameMapper.getCosmosDBRegionName(target); + for (String region : regions) { + if (RegionNameMapper.getCosmosDBRegionName(region).equalsIgnoreCase(normalizedTarget)) { + return true; + } + } + return false; + } + static class DatabaseAccountLocationsInfo { private UnmodifiableList writeRegionalRoutingContexts; private UnmodifiableList readRegionalRoutingContexts; @@ -1066,7 +1095,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 -> RegionNameMapper.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/RegionNameMapper.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java new file mode 100644 index 000000000000..4d1f3edc1f98 --- /dev/null +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java @@ -0,0 +1,159 @@ +// 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.Collections; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Maps region name variants (any casing/spacing) to the canonical CosmosDB region name format. + *

+ * For example, "westus2" → "West US 2", "west us 3" → "West US 3", "EAST US" → "East US". + *

+ * If the input region name is not recognized, it is returned as-is to support forward compatibility + * with new Azure regions that may not yet be in the static list. + */ +public final class RegionNameMapper { + + private static final Map NORMALIZED_TO_CANONICAL; + + static { + Map map = new HashMap<>(); + + // Americas + addMapping(map, "West US"); + addMapping(map, "West US 2"); + addMapping(map, "West US 3"); + addMapping(map, "West Central US"); + addMapping(map, "East US"); + addMapping(map, "East US 2"); + addMapping(map, "East US 3"); + addMapping(map, "Central US"); + addMapping(map, "South Central US"); + addMapping(map, "North Central US"); + addMapping(map, "Canada East"); + addMapping(map, "Canada Central"); + addMapping(map, "Brazil South"); + addMapping(map, "Brazil Southeast"); + addMapping(map, "Mexico Central"); + addMapping(map, "Chile Central"); + + // Europe + addMapping(map, "North Europe"); + addMapping(map, "West Europe"); + addMapping(map, "France Central"); + addMapping(map, "France South"); + addMapping(map, "UK West"); + addMapping(map, "UK South"); + addMapping(map, "Germany North"); + addMapping(map, "Germany West Central"); + addMapping(map, "Germany Central"); + addMapping(map, "Germany Northeast"); + addMapping(map, "Switzerland North"); + addMapping(map, "Switzerland West"); + addMapping(map, "Norway East"); + addMapping(map, "Norway West"); + addMapping(map, "Sweden Central"); + addMapping(map, "Sweden South"); + addMapping(map, "Poland Central"); + addMapping(map, "Italy North"); + addMapping(map, "Spain Central"); + addMapping(map, "Austria East"); + addMapping(map, "Belgium Central"); + addMapping(map, "Denmark East"); + addMapping(map, "Finland Central"); + addMapping(map, "Greece Central"); + + // Asia Pacific + addMapping(map, "East Asia"); + addMapping(map, "Southeast Asia"); + addMapping(map, "Japan East"); + addMapping(map, "Japan West"); + addMapping(map, "Australia East"); + addMapping(map, "Australia Southeast"); + addMapping(map, "Australia Central"); + addMapping(map, "Australia Central 2"); + addMapping(map, "Central India"); + addMapping(map, "West India"); + addMapping(map, "South India"); + addMapping(map, "Jio India Central"); + addMapping(map, "Jio India West"); + addMapping(map, "Korea Central"); + addMapping(map, "Korea South"); + addMapping(map, "New Zealand North"); + addMapping(map, "Indonesia Central"); + addMapping(map, "Malaysia South"); + addMapping(map, "Malaysia West"); + addMapping(map, "Taiwan North"); + addMapping(map, "Taiwan Northwest"); + + // Middle East & Africa + addMapping(map, "UAE Central"); + addMapping(map, "UAE North"); + addMapping(map, "South Africa North"); + addMapping(map, "South Africa West"); + addMapping(map, "Qatar Central"); + addMapping(map, "Israel Central"); + addMapping(map, "Israel Northwest"); + addMapping(map, "Saudi Arabia East"); + + // China + addMapping(map, "China East"); + addMapping(map, "China East 2"); + addMapping(map, "China East 3"); + addMapping(map, "China North"); + addMapping(map, "China North 2"); + addMapping(map, "China North 3"); + + // US Government + addMapping(map, "USGov Virginia"); + addMapping(map, "USGov Iowa"); + addMapping(map, "USGov Arizona"); + addMapping(map, "USGov Texas"); + addMapping(map, "USDoD Central"); + addMapping(map, "USDoD East"); + addMapping(map, "USNat East"); + addMapping(map, "USNat West"); + addMapping(map, "USSec East"); + addMapping(map, "USSec West"); + addMapping(map, "USSec West Central"); + + // EUAP / Canary + addMapping(map, "Central US EUAP"); + addMapping(map, "East US 2 EUAP"); + + NORMALIZED_TO_CANONICAL = Collections.unmodifiableMap(map); + } + + private RegionNameMapper() { + } + + /** + * Normalizes a region name to the canonical CosmosDB format. + *

+ * Strips spaces, lowercases, and looks up in the known-region map. + * If recognized, returns the canonical form (e.g., "West US 3"). + * If not recognized, returns the input as-is for forward compatibility. + * + * @param regionName the region name to normalize (any casing/spacing variant) + * @return the canonical CosmosDB region name, or the original input if unrecognized + */ + public static String getCosmosDBRegionName(String regionName) { + if (StringUtils.isEmpty(regionName)) { + return regionName; + } + + String normalized = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + return NORMALIZED_TO_CANONICAL.getOrDefault(normalized, regionName); + } + + private static void addMapping(Map map, String canonicalName) { + String key = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); + map.putIfAbsent(key, canonicalName); + } +} From 2afefb9f4db37c60ba368e5e96df7fb34ee26dbc Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 10:12:37 -0400 Subject: [PATCH 02/14] Add dynamic region registration from server responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The static region list in RegionNameMapper goes stale when new Azure regions are added. Fix: add a ConcurrentHashMap-backed dynamic tier that learns canonical region names from server responses. - RegionNameMapper.registerRegionName(): registers canonical names from DatabaseAccountLocation (called from LocationCache.addRoutingContexts). After the first account read, even new regions like 'West US 4' can normalize 'westus4' → 'West US 4'. - getCosmosDBRegionName(): checks static map first, then dynamic map. - Add 2 new tests for dynamic registration behavior. - 45/45 RegionNameMapperTest pass, 32/32 LocationCacheTest pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../routing/RegionNameMapperTest.java | 27 ++++++++ .../implementation/routing/LocationCache.java | 4 ++ .../routing/RegionNameMapper.java | 65 +++++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java index 19984f36a0f6..4e6e8e9b4348 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java @@ -98,4 +98,31 @@ public void shouldHandleBlankString() { // Blank strings (only spaces) → stripped to "" → not in map → returned as-is assertThat(RegionNameMapper.getCosmosDBRegionName(" ")).isEqualTo(" "); } +<<<<<<< Updated upstream +======= + + @Test(groups = "unit") + public void shouldNormalizeAfterDynamicRegistration() { + // Before registration: unknown region passes through as-is + String unknownSpaceStripped = "futureregion99"; + assertThat(RegionNameMapper.getCosmosDBRegionName(unknownSpaceStripped)).isEqualTo(unknownSpaceStripped); + + // Simulate server returning this new region name + RegionNameMapper.registerRegionName("Future Region 99"); + + // After registration: all variants normalize to canonical form + assertThat(RegionNameMapper.getCosmosDBRegionName("futureregion99")).isEqualTo("Future Region 99"); + assertThat(RegionNameMapper.getCosmosDBRegionName("future region 99")).isEqualTo("Future Region 99"); + assertThat(RegionNameMapper.getCosmosDBRegionName("FUTURE REGION 99")).isEqualTo("Future Region 99"); + assertThat(RegionNameMapper.getCosmosDBRegionName("FutureRegion99")).isEqualTo("Future Region 99"); + assertThat(RegionNameMapper.getCosmosDBRegionName("Future Region 99")).isEqualTo("Future Region 99"); + } + + @Test(groups = "unit") + public void dynamicRegistrationShouldNotOverrideStaticEntries() { + // "West US" is in the static map — dynamic registration should not overwrite it + RegionNameMapper.registerRegionName("west us"); // wrong casing + assertThat(RegionNameMapper.getCosmosDBRegionName("westus")).isEqualTo("West US"); + } +>>>>>>> Stashed changes } 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 c59c536761e4..25563f1f02cd 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 @@ -913,6 +913,10 @@ private void addRoutingContexts( if (!Strings.isNullOrEmpty(gatewayDbAccountLocation.getName())) { try { + // Register the canonical server name so future user inputs + // (e.g., "westus4") can be normalized even for new regions + RegionNameMapper.registerRegionName(gatewayDbAccountLocation.getName()); + String location = gatewayDbAccountLocation.getName().toLowerCase(Locale.ROOT); URI endpoint = new URI(gatewayDbAccountLocation.getEndpoint().toLowerCase(Locale.ROOT)); diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java index 4d1f3edc1f98..c29b230771d5 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java @@ -9,19 +9,41 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; +<<<<<<< Updated upstream +======= +import java.util.concurrent.ConcurrentHashMap; +>>>>>>> Stashed changes /** * Maps region name variants (any casing/spacing) to the canonical CosmosDB region name format. *

* For example, "westus2" → "West US 2", "west us 3" → "West US 3", "EAST US" → "East US". *

+<<<<<<< Updated upstream * If the input region name is not recognized, it is returned as-is to support forward compatibility * with new Azure regions that may not yet be in the static list. +======= + * Uses a two-tier lookup: + *

    + *
  1. A static map of well-known Azure regions (compiled into the SDK).
  2. + *
  3. A dynamic map populated at runtime from server responses (covers new regions + * not yet in the static list).
  4. + *
+ * If the input region name is not found in either map, it is returned as-is. +>>>>>>> Stashed changes */ public final class RegionNameMapper { private static final Map NORMALIZED_TO_CANONICAL; +<<<<<<< Updated upstream +======= + // Dynamic map populated from server-returned DatabaseAccountLocation names. + // This ensures new Azure regions not yet in the static list are still normalized + // correctly after the first account read. + private static final ConcurrentHashMap DYNAMIC_NORMALIZED_TO_CANONICAL = new ConcurrentHashMap<>(); + +>>>>>>> Stashed changes static { Map map = new HashMap<>(); @@ -136,9 +158,15 @@ private RegionNameMapper() { /** * Normalizes a region name to the canonical CosmosDB format. *

+<<<<<<< Updated upstream * Strips spaces, lowercases, and looks up in the known-region map. * If recognized, returns the canonical form (e.g., "West US 3"). * If not recognized, returns the input as-is for forward compatibility. +======= + * Strips spaces, lowercases, and looks up in both the static known-region map + * and the dynamic map (populated from server responses). If recognized, returns + * the canonical form (e.g., "West US 3"). If not recognized, returns the input as-is. +>>>>>>> Stashed changes * * @param regionName the region name to normalize (any casing/spacing variant) * @return the canonical CosmosDB region name, or the original input if unrecognized @@ -149,7 +177,44 @@ public static String getCosmosDBRegionName(String regionName) { } String normalized = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); +<<<<<<< Updated upstream return NORMALIZED_TO_CANONICAL.getOrDefault(normalized, regionName); +======= + + // Check static map first (most common case) + String canonical = NORMALIZED_TO_CANONICAL.get(normalized); + if (canonical != null) { + return canonical; + } + + // Check dynamic map (covers new regions learned from server responses) + canonical = DYNAMIC_NORMALIZED_TO_CANONICAL.get(normalized); + if (canonical != null) { + return canonical; + } + + return regionName; + } + + /** + * Registers a canonical region name learned from a server response. + *

+ * Called when processing {@code DatabaseAccountLocation} names from the account read response. + * This ensures that new Azure regions (not yet in the static list) can still be normalized + * correctly for subsequent preferred-region or exclude-region lookups. + * + * @param canonicalRegionName the canonical region name from the server (e.g., "West US 4") + */ + public static void registerRegionName(String canonicalRegionName) { + if (StringUtils.isEmpty(canonicalRegionName)) { + return; + } + String key = canonicalRegionName.toLowerCase(Locale.ROOT).replace(" ", ""); + // Only add if not already in the static map + if (!NORMALIZED_TO_CANONICAL.containsKey(key)) { + DYNAMIC_NORMALIZED_TO_CANONICAL.putIfAbsent(key, canonicalRegionName); + } +>>>>>>> Stashed changes } private static void addMapping(Map map, String canonicalName) { From 25a47ea7b3b85334a1a095a058d85a9c346cacfe Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 10:22:28 -0400 Subject: [PATCH 03/14] Fix merge conflict markers left in RegionNameMapper and test The previous commit had stash conflict markers (<<<<<<< Updated upstream / >>>>>>> Stashed changes) left in RegionNameMapper.java and RegionNameMapperTest.java. Rewrote both files clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../routing/RegionNameMapperTest.java | 3 --- .../routing/RegionNameMapper.java | 21 ------------------- 2 files changed, 24 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java index 4e6e8e9b4348..ea22c7ef8741 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java @@ -98,8 +98,6 @@ public void shouldHandleBlankString() { // Blank strings (only spaces) → stripped to "" → not in map → returned as-is assertThat(RegionNameMapper.getCosmosDBRegionName(" ")).isEqualTo(" "); } -<<<<<<< Updated upstream -======= @Test(groups = "unit") public void shouldNormalizeAfterDynamicRegistration() { @@ -124,5 +122,4 @@ public void dynamicRegistrationShouldNotOverrideStaticEntries() { RegionNameMapper.registerRegionName("west us"); // wrong casing assertThat(RegionNameMapper.getCosmosDBRegionName("westus")).isEqualTo("West US"); } ->>>>>>> Stashed changes } diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java index c29b230771d5..793c11fcfa71 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java @@ -9,20 +9,13 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; -<<<<<<< Updated upstream -======= import java.util.concurrent.ConcurrentHashMap; ->>>>>>> Stashed changes /** * Maps region name variants (any casing/spacing) to the canonical CosmosDB region name format. *

* For example, "westus2" → "West US 2", "west us 3" → "West US 3", "EAST US" → "East US". *

-<<<<<<< Updated upstream - * If the input region name is not recognized, it is returned as-is to support forward compatibility - * with new Azure regions that may not yet be in the static list. -======= * Uses a two-tier lookup: *

    *
  1. A static map of well-known Azure regions (compiled into the SDK).
  2. @@ -30,20 +23,16 @@ * not yet in the static list). *
* If the input region name is not found in either map, it is returned as-is. ->>>>>>> Stashed changes */ public final class RegionNameMapper { private static final Map NORMALIZED_TO_CANONICAL; -<<<<<<< Updated upstream -======= // Dynamic map populated from server-returned DatabaseAccountLocation names. // This ensures new Azure regions not yet in the static list are still normalized // correctly after the first account read. private static final ConcurrentHashMap DYNAMIC_NORMALIZED_TO_CANONICAL = new ConcurrentHashMap<>(); ->>>>>>> Stashed changes static { Map map = new HashMap<>(); @@ -158,15 +147,9 @@ private RegionNameMapper() { /** * Normalizes a region name to the canonical CosmosDB format. *

-<<<<<<< Updated upstream - * Strips spaces, lowercases, and looks up in the known-region map. - * If recognized, returns the canonical form (e.g., "West US 3"). - * If not recognized, returns the input as-is for forward compatibility. -======= * Strips spaces, lowercases, and looks up in both the static known-region map * and the dynamic map (populated from server responses). If recognized, returns * the canonical form (e.g., "West US 3"). If not recognized, returns the input as-is. ->>>>>>> Stashed changes * * @param regionName the region name to normalize (any casing/spacing variant) * @return the canonical CosmosDB region name, or the original input if unrecognized @@ -177,9 +160,6 @@ public static String getCosmosDBRegionName(String regionName) { } String normalized = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); -<<<<<<< Updated upstream - return NORMALIZED_TO_CANONICAL.getOrDefault(normalized, regionName); -======= // Check static map first (most common case) String canonical = NORMALIZED_TO_CANONICAL.get(normalized); @@ -214,7 +194,6 @@ public static void registerRegionName(String canonicalRegionName) { if (!NORMALIZED_TO_CANONICAL.containsKey(key)) { DYNAMIC_NORMALIZED_TO_CANONICAL.putIfAbsent(key, canonicalRegionName); } ->>>>>>> Stashed changes } private static void addMapping(Map map, String canonicalName) { From 849bcf34f10aba5ddb5a2b368e1273e3963124da Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 11:26:50 -0400 Subject: [PATCH 04/14] Consolidate RegionNameMapper into RegionNameToRegionIdMap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the separate RegionNameMapper into RegionNameToRegionIdMap as the single source of truth for region names. This eliminates maintaining two parallel region lists that can drift out of sync. Changes: - Delete RegionNameMapper.java — normalization logic moved into RegionNameToRegionIdMap. - RegionNameToRegionIdMap now provides region ID mapping (existing) AND region name normalization (new) from one canonical list. - Sync REGION_NAME_TO_REGION_ID_MAPPINGS with backend RegionToIdMap.cs: add Bleu France Central/South (107/108), Delos Cloud Germany Central/North (109/110), Singapore Central/North (111/112), fix 'easteurope' → 'East Europe' (54). - Build NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS programmatically from REGION_NAME_TO_REGION_ID_MAPPINGS instead of manual duplication. - Normalization static map seeded from ID map keys + additional regions without IDs yet (from .NET SDK Regions.cs). - Rename test: RegionNameMapperTest → RegionNameToRegionIdMapNormalizationTest. - Update ConnectionPolicy and LocationCache references. - All 78 tests pass (45 normalization + 32 LocationCache + 1 consistency). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...onNameToRegionIdMapNormalizationTest.java} | 34 +- .../implementation/ConnectionPolicy.java | 4 +- .../implementation/routing/LocationCache.java | 10 +- .../routing/RegionNameMapper.java | 203 ---------- .../routing/RegionNameToRegionIdMap.java | 348 +++++++++++------- 5 files changed, 231 insertions(+), 368 deletions(-) rename sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/{RegionNameMapperTest.java => RegionNameToRegionIdMapNormalizationTest.java} (70%) delete mode 100644 sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java similarity index 70% rename from sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java rename to sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java index ea22c7ef8741..7cdb42f968f2 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameMapperTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java @@ -9,9 +9,9 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link RegionNameMapper} + * Tests for {@link RegionNameToRegionIdMap} */ -public class RegionNameMapperTest { +public class RegionNameToRegionIdMapNormalizationTest { @DataProvider(name = "regionNameVariants") public Object[][] regionNameVariants() { @@ -76,50 +76,50 @@ public Object[][] regionNameVariants() { @Test(groups = "unit", dataProvider = "regionNameVariants") public void shouldNormalizeRegionNameVariants(String input, String expectedCanonical) { - String result = RegionNameMapper.getCosmosDBRegionName(input); + String result = RegionNameToRegionIdMap.getCosmosDBRegionName(input); assertThat(result).isEqualTo(expectedCanonical); } @Test(groups = "unit") public void shouldPassthroughUnknownRegions() { // Unknown regions should be returned as-is for forward compatibility - assertThat(RegionNameMapper.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); - assertThat(RegionNameMapper.getCosmosDBRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); } @Test(groups = "unit") public void shouldHandleNullAndEmpty() { - assertThat(RegionNameMapper.getCosmosDBRegionName(null)).isNull(); - assertThat(RegionNameMapper.getCosmosDBRegionName("")).isEqualTo(""); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName(null)).isNull(); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("")).isEqualTo(""); } @Test(groups = "unit") public void shouldHandleBlankString() { // Blank strings (only spaces) → stripped to "" → not in map → returned as-is - assertThat(RegionNameMapper.getCosmosDBRegionName(" ")).isEqualTo(" "); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName(" ")).isEqualTo(" "); } @Test(groups = "unit") public void shouldNormalizeAfterDynamicRegistration() { // Before registration: unknown region passes through as-is String unknownSpaceStripped = "futureregion99"; - assertThat(RegionNameMapper.getCosmosDBRegionName(unknownSpaceStripped)).isEqualTo(unknownSpaceStripped); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName(unknownSpaceStripped)).isEqualTo(unknownSpaceStripped); // Simulate server returning this new region name - RegionNameMapper.registerRegionName("Future Region 99"); + RegionNameToRegionIdMap.registerRegionName("Future Region 99"); // After registration: all variants normalize to canonical form - assertThat(RegionNameMapper.getCosmosDBRegionName("futureregion99")).isEqualTo("Future Region 99"); - assertThat(RegionNameMapper.getCosmosDBRegionName("future region 99")).isEqualTo("Future Region 99"); - assertThat(RegionNameMapper.getCosmosDBRegionName("FUTURE REGION 99")).isEqualTo("Future Region 99"); - assertThat(RegionNameMapper.getCosmosDBRegionName("FutureRegion99")).isEqualTo("Future Region 99"); - assertThat(RegionNameMapper.getCosmosDBRegionName("Future Region 99")).isEqualTo("Future Region 99"); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("futureregion99")).isEqualTo("Future Region 99"); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("future region 99")).isEqualTo("Future Region 99"); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("FUTURE REGION 99")).isEqualTo("Future Region 99"); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("FutureRegion99")).isEqualTo("Future Region 99"); + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("Future Region 99")).isEqualTo("Future Region 99"); } @Test(groups = "unit") public void dynamicRegistrationShouldNotOverrideStaticEntries() { // "West US" is in the static map — dynamic registration should not overwrite it - RegionNameMapper.registerRegionName("west us"); // wrong casing - assertThat(RegionNameMapper.getCosmosDBRegionName("westus")).isEqualTo("West US"); + RegionNameToRegionIdMap.registerRegionName("west us"); // wrong casing + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("westus")).isEqualTo("West US"); } } 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 b71e77e9bf57..631ca317285e 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,7 @@ import com.azure.cosmos.Http2ConnectionConfig; import com.azure.cosmos.ThrottlingRetryOptions; -import com.azure.cosmos.implementation.routing.RegionNameMapper; +import com.azure.cosmos.implementation.routing.RegionNameToRegionIdMap; import java.time.Duration; import java.util.ArrayList; @@ -497,7 +497,7 @@ public ConnectionPolicy setPreferredRegions(List preferredRegions) { LinkedHashSet deduped = new LinkedHashSet<>(); for (String region : preferredRegions) { if (region != null) { - deduped.add(RegionNameMapper.getCosmosDBRegionName(region)); + deduped.add(RegionNameToRegionIdMap.getCosmosDBRegionName(region)); } } this.preferredRegions = new ArrayList<>(deduped); 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 25563f1f02cd..8bedf1cc7c58 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 @@ -915,7 +915,7 @@ private void addRoutingContexts( // Register the canonical server name so future user inputs // (e.g., "westus4") can be normalized even for new regions - RegionNameMapper.registerRegionName(gatewayDbAccountLocation.getName()); + RegionNameToRegionIdMap.registerRegionName(gatewayDbAccountLocation.getName()); String location = gatewayDbAccountLocation.getName().toLowerCase(Locale.ROOT); URI endpoint = new URI(gatewayDbAccountLocation.getEndpoint().toLowerCase(Locale.ROOT)); @@ -1061,7 +1061,7 @@ private static List normalizeRegionNames(List regionNames) { List normalized = new ArrayList<>(regionNames.size()); for (String region : regionNames) { if (region != null) { - normalized.add(RegionNameMapper.getCosmosDBRegionName(region)); + normalized.add(RegionNameToRegionIdMap.getCosmosDBRegionName(region)); } } return normalized; @@ -1071,9 +1071,9 @@ private static boolean containsRegionIgnoreCase(List regions, String tar if (regions == null || regions.isEmpty()) { return false; } - String normalizedTarget = RegionNameMapper.getCosmosDBRegionName(target); + String normalizedTarget = RegionNameToRegionIdMap.getCosmosDBRegionName(target); for (String region : regions) { - if (RegionNameMapper.getCosmosDBRegionName(region).equalsIgnoreCase(normalizedTarget)) { + if (RegionNameToRegionIdMap.getCosmosDBRegionName(region).equalsIgnoreCase(normalizedTarget)) { return true; } } @@ -1099,7 +1099,7 @@ static class DatabaseAccountLocationsInfo { public DatabaseAccountLocationsInfo(List preferredLocations, RegionalRoutingContext defaultRoutingContext) { - this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> RegionNameMapper.getCosmosDBRegionName(loc).toLowerCase(Locale.ROOT)).collect(Collectors.toList())); + this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> RegionNameToRegionIdMap.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/RegionNameMapper.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java deleted file mode 100644 index 793c11fcfa71..000000000000 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameMapper.java +++ /dev/null @@ -1,203 +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.Collections; -import java.util.HashMap; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Maps region name variants (any casing/spacing) to the canonical CosmosDB region name format. - *

- * For example, "westus2" → "West US 2", "west us 3" → "West US 3", "EAST US" → "East US". - *

- * Uses a two-tier lookup: - *

    - *
  1. A static map of well-known Azure regions (compiled into the SDK).
  2. - *
  3. A dynamic map populated at runtime from server responses (covers new regions - * not yet in the static list).
  4. - *
- * If the input region name is not found in either map, it is returned as-is. - */ -public final class RegionNameMapper { - - private static final Map NORMALIZED_TO_CANONICAL; - - // Dynamic map populated from server-returned DatabaseAccountLocation names. - // This ensures new Azure regions not yet in the static list are still normalized - // correctly after the first account read. - private static final ConcurrentHashMap DYNAMIC_NORMALIZED_TO_CANONICAL = new ConcurrentHashMap<>(); - - static { - Map map = new HashMap<>(); - - // Americas - addMapping(map, "West US"); - addMapping(map, "West US 2"); - addMapping(map, "West US 3"); - addMapping(map, "West Central US"); - addMapping(map, "East US"); - addMapping(map, "East US 2"); - addMapping(map, "East US 3"); - addMapping(map, "Central US"); - addMapping(map, "South Central US"); - addMapping(map, "North Central US"); - addMapping(map, "Canada East"); - addMapping(map, "Canada Central"); - addMapping(map, "Brazil South"); - addMapping(map, "Brazil Southeast"); - addMapping(map, "Mexico Central"); - addMapping(map, "Chile Central"); - - // Europe - addMapping(map, "North Europe"); - addMapping(map, "West Europe"); - addMapping(map, "France Central"); - addMapping(map, "France South"); - addMapping(map, "UK West"); - addMapping(map, "UK South"); - addMapping(map, "Germany North"); - addMapping(map, "Germany West Central"); - addMapping(map, "Germany Central"); - addMapping(map, "Germany Northeast"); - addMapping(map, "Switzerland North"); - addMapping(map, "Switzerland West"); - addMapping(map, "Norway East"); - addMapping(map, "Norway West"); - addMapping(map, "Sweden Central"); - addMapping(map, "Sweden South"); - addMapping(map, "Poland Central"); - addMapping(map, "Italy North"); - addMapping(map, "Spain Central"); - addMapping(map, "Austria East"); - addMapping(map, "Belgium Central"); - addMapping(map, "Denmark East"); - addMapping(map, "Finland Central"); - addMapping(map, "Greece Central"); - - // Asia Pacific - addMapping(map, "East Asia"); - addMapping(map, "Southeast Asia"); - addMapping(map, "Japan East"); - addMapping(map, "Japan West"); - addMapping(map, "Australia East"); - addMapping(map, "Australia Southeast"); - addMapping(map, "Australia Central"); - addMapping(map, "Australia Central 2"); - addMapping(map, "Central India"); - addMapping(map, "West India"); - addMapping(map, "South India"); - addMapping(map, "Jio India Central"); - addMapping(map, "Jio India West"); - addMapping(map, "Korea Central"); - addMapping(map, "Korea South"); - addMapping(map, "New Zealand North"); - addMapping(map, "Indonesia Central"); - addMapping(map, "Malaysia South"); - addMapping(map, "Malaysia West"); - addMapping(map, "Taiwan North"); - addMapping(map, "Taiwan Northwest"); - - // Middle East & Africa - addMapping(map, "UAE Central"); - addMapping(map, "UAE North"); - addMapping(map, "South Africa North"); - addMapping(map, "South Africa West"); - addMapping(map, "Qatar Central"); - addMapping(map, "Israel Central"); - addMapping(map, "Israel Northwest"); - addMapping(map, "Saudi Arabia East"); - - // China - addMapping(map, "China East"); - addMapping(map, "China East 2"); - addMapping(map, "China East 3"); - addMapping(map, "China North"); - addMapping(map, "China North 2"); - addMapping(map, "China North 3"); - - // US Government - addMapping(map, "USGov Virginia"); - addMapping(map, "USGov Iowa"); - addMapping(map, "USGov Arizona"); - addMapping(map, "USGov Texas"); - addMapping(map, "USDoD Central"); - addMapping(map, "USDoD East"); - addMapping(map, "USNat East"); - addMapping(map, "USNat West"); - addMapping(map, "USSec East"); - addMapping(map, "USSec West"); - addMapping(map, "USSec West Central"); - - // EUAP / Canary - addMapping(map, "Central US EUAP"); - addMapping(map, "East US 2 EUAP"); - - NORMALIZED_TO_CANONICAL = Collections.unmodifiableMap(map); - } - - private RegionNameMapper() { - } - - /** - * Normalizes a region name to the canonical CosmosDB format. - *

- * Strips spaces, lowercases, and looks up in both the static known-region map - * and the dynamic map (populated from server responses). If recognized, returns - * the canonical form (e.g., "West US 3"). If not recognized, returns the input as-is. - * - * @param regionName the region name to normalize (any casing/spacing variant) - * @return the canonical CosmosDB region name, or the original input if unrecognized - */ - public static String getCosmosDBRegionName(String regionName) { - if (StringUtils.isEmpty(regionName)) { - return regionName; - } - - String normalized = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); - - // Check static map first (most common case) - String canonical = NORMALIZED_TO_CANONICAL.get(normalized); - if (canonical != null) { - return canonical; - } - - // Check dynamic map (covers new regions learned from server responses) - canonical = DYNAMIC_NORMALIZED_TO_CANONICAL.get(normalized); - if (canonical != null) { - return canonical; - } - - return regionName; - } - - /** - * Registers a canonical region name learned from a server response. - *

- * Called when processing {@code DatabaseAccountLocation} names from the account read response. - * This ensures that new Azure regions (not yet in the static list) can still be normalized - * correctly for subsequent preferred-region or exclude-region lookups. - * - * @param canonicalRegionName the canonical region name from the server (e.g., "West US 4") - */ - public static void registerRegionName(String canonicalRegionName) { - if (StringUtils.isEmpty(canonicalRegionName)) { - return; - } - String key = canonicalRegionName.toLowerCase(Locale.ROOT).replace(" ", ""); - // Only add if not already in the static map - if (!NORMALIZED_TO_CANONICAL.containsKey(key)) { - DYNAMIC_NORMALIZED_TO_CANONICAL.putIfAbsent(key, canonicalRegionName); - } - } - - private static void addMapping(Map map, String canonicalName) { - String key = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); - map.putIfAbsent(key, canonicalName); - } -} 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 index cfd522641888..e4a00c4cb734 100644 --- 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 @@ -5,16 +5,32 @@ import com.azure.cosmos.implementation.apachecommons.lang.StringUtils; +import java.util.Collections; import java.util.HashMap; +import java.util.Locale; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** - * ATTENTION: Please ensure the below map is consistent with RegionToIdMap.cs to avoid breaking behavior. + * Single source of truth for Azure region name mappings in the Cosmos Java SDK. *

- * 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 - * */ + * Provides three capabilities: + *

    + *
  1. Region ID mapping — canonical name ↔ numeric ID for session token region-level progress tracking. + * Must stay in sync with + * RegionToIdMap.cs.
  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").
  4. + *
  5. Dynamic registration — learns new canonical names from server responses at runtime, so regions + * not yet in the static list can still be normalized after the first account read.
  6. + *
+ */ public class RegionNameToRegionIdMap { + + // ======================================================================== + // Region ID mappings (synced with backend RegionToIdMap.cs) + // ======================================================================== + public static final Map REGION_NAME_TO_REGION_ID_MAPPINGS = new HashMap() { { put("East US", 1); @@ -70,7 +86,7 @@ public class RegionNameToRegionIdMap { put("Central US EUAP", 51); put("East US 2 EUAP", 52); put("North Europe 2", 53); - put("easteurope", 54); + put("East Europe", 54); put("APAC Southeast 2", 55); put("UK South 2", 56); put("UK North", 57); @@ -90,167 +106,112 @@ public class RegionNameToRegionIdMap { put("Sweden Central", 71); put("Sweden South", 72); put("Korea South 2", 73); + 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); } }; public static final Map REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS = new HashMap() { { - put(49, "uaecentral"); + put(1, "eastus"); + put(2, "eastus2"); + put(3, "centralus"); + put(4, "northcentralus"); + put(5, "southcentralus"); + put(6, "westcentralus"); + put(7, "westus"); + put(8, "westus2"); + put(9, "canadaeast"); + put(10, "canadacentral"); + put(11, "brazilsouth"); + put(12, "northeurope"); + put(13, "westeurope"); put(14, "francecentral"); - put(65, "usdodsouthcentral"); + put(15, "francesouth"); + put(16, "ukwest"); + put(17, "uksouth"); + put(18, "germanycentral"); + put(19, "germanynortheast"); + put(20, "germanynorth"); + put(21, "germanywestcentral"); + put(22, "switzerlandnorth"); + put(23, "switzerlandwest"); + put(24, "southeastasia"); + put(25, "eastasia"); 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(29, "australiacentral2"); + put(30, "chinaeast"); + put(31, "chinanorth"); put(32, "centralindia"); + put(33, "westindia"); + put(34, "southindia"); put(35, "japaneast"); + put(36, "japanwest"); + put(37, "koreacentral"); + put(38, "koreasouth"); + put(39, "usgovvirginia"); + put(40, "usgoviowa"); + put(41, "usgovarizona"); + put(42, "usgovtexas"); + put(43, "usdodeast"); + put(44, "usdodcentral"); put(45, "usseceast"); - put(25, "eastasia"); - put(6, "westcentralus"); - put(19, "germanynortheast"); - put(23, "switzerlandwest"); + put(46, "ussecwest"); + put(47, "southafricawest"); + put(48, "southafricanorth"); + put(49, "uaecentral"); + put(50, "uaenorth"); + put(51, "centraluseuap"); 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(53, "northeurope2"); put(54, "easteurope"); - put(42, "usgovtexas"); - put(61, "norwaywest"); put(55, "apacsoutheast2"); - put(12, "northeurope"); + put(56, "uksouth2"); + put(57, "uknorth"); + put(58, "eastusstg"); put(59, "southcentralusstg"); - put(21, "germanywestcentral"); - put(24, "southeastasia"); - put(71, "swedencentral"); - put(31, "chinanorth"); + put(60, "norwayeast"); + put(61, "norwaywest"); 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(63, "usdodsouthwest"); put(64, "usdodwestcentral"); + put(65, "usdodsouthcentral"); + put(66, "chinaeast2"); + put(67, "chinanorth2"); + put(68, "usnateast"); + put(69, "usnatwest"); 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(71, "swedencentral"); + put(72, "swedensouth"); + put(73, "koreasouth2"); + put(107, "bleufrancecentral"); + put(108, "bleufrancesouth"); + put(109, "deloscloudgermanycentral"); + put(110, "deloscloudgermanynorth"); + put(111, "singaporecentral"); + put(112, "singaporenorth"); 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 final Map NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS; + + static { + // Build normalized→ID map from REGION_NAME_TO_REGION_ID_MAPPINGS + Map normalizedMap = new HashMap<>(); + for (Map.Entry entry : REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { + String normalized = entry.getKey().toLowerCase(Locale.ROOT).replace(" ", ""); + normalizedMap.put(normalized, entry.getValue()); } - }; + NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(normalizedMap); + } public static String getRegionName(int regionId) { return REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.getOrDefault(regionId, StringUtils.EMPTY); @@ -259,4 +220,109 @@ public static String getRegionName(int regionId) { public static int getRegionId(String regionName) { return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(regionName, -1); } + + // ======================================================================== + // Region name normalization (any variant → canonical CosmosDB format) + // ======================================================================== + + // Static map: lowercase-no-spaces key → canonical display name + private static final Map NORMALIZED_TO_CANONICAL; + + // Dynamic map: populated from server-returned DatabaseAccountLocation names + // at runtime, so new regions not in the static list can still be normalized + private static final ConcurrentHashMap DYNAMIC_NORMALIZED_TO_CANONICAL = new ConcurrentHashMap<>(); + + static { + Map map = new HashMap<>(); + + // Seed from the ID map (all backend-known regions) + for (String canonicalName : REGION_NAME_TO_REGION_ID_MAPPINGS.keySet()) { + addCanonicalMapping(map, canonicalName); + } + + // Additional regions that don't have IDs yet (from .NET SDK Regions.cs / Azure portal) + addCanonicalMapping(map, "West US 3"); + addCanonicalMapping(map, "East US 3"); + addCanonicalMapping(map, "Brazil Southeast"); + addCanonicalMapping(map, "Mexico Central"); + addCanonicalMapping(map, "Chile Central"); + addCanonicalMapping(map, "Poland Central"); + addCanonicalMapping(map, "Italy North"); + addCanonicalMapping(map, "Spain Central"); + addCanonicalMapping(map, "Austria East"); + addCanonicalMapping(map, "Belgium Central"); + addCanonicalMapping(map, "Denmark East"); + addCanonicalMapping(map, "Finland Central"); + addCanonicalMapping(map, "Greece Central"); + addCanonicalMapping(map, "Jio India Central"); + addCanonicalMapping(map, "Jio India West"); + addCanonicalMapping(map, "New Zealand North"); + addCanonicalMapping(map, "Indonesia Central"); + addCanonicalMapping(map, "Malaysia South"); + addCanonicalMapping(map, "Malaysia West"); + addCanonicalMapping(map, "Taiwan North"); + addCanonicalMapping(map, "Taiwan Northwest"); + addCanonicalMapping(map, "Qatar Central"); + addCanonicalMapping(map, "Israel Central"); + addCanonicalMapping(map, "Israel Northwest"); + addCanonicalMapping(map, "Saudi Arabia East"); + addCanonicalMapping(map, "China East 3"); + addCanonicalMapping(map, "China North 3"); + + NORMALIZED_TO_CANONICAL = Collections.unmodifiableMap(map); + } + + /** + * Normalizes a region name to the canonical CosmosDB format. + *

+ * Strips spaces, lowercases, and looks up in both the static known-region map + * and the dynamic map (populated from server responses). If recognized, returns + * the canonical form (e.g., "West US 3"). If not recognized, returns the input as-is. + * + * @param regionName the region name to normalize (any casing/spacing variant) + * @return the canonical CosmosDB region name, or the original input 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; + } + + canonical = DYNAMIC_NORMALIZED_TO_CANONICAL.get(normalized); + if (canonical != null) { + return canonical; + } + + return regionName; + } + + /** + * Registers a canonical region name learned from a server response. + *

+ * Called when processing {@code DatabaseAccountLocation} names from the account read response. + * This ensures that new Azure regions (not yet in the static list) can still be normalized + * correctly for subsequent preferred-region or exclude-region lookups. + * + * @param canonicalRegionName the canonical region name from the server (e.g., "West US 4") + */ + public static void registerRegionName(String canonicalRegionName) { + if (StringUtils.isEmpty(canonicalRegionName)) { + return; + } + String key = canonicalRegionName.toLowerCase(Locale.ROOT).replace(" ", ""); + if (!NORMALIZED_TO_CANONICAL.containsKey(key)) { + DYNAMIC_NORMALIZED_TO_CANONICAL.putIfAbsent(key, canonicalRegionName); + } + } + + private static void addCanonicalMapping(Map map, String canonicalName) { + String key = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); + map.putIfAbsent(key, canonicalName); + } } From 1d07697052b69426129ddcb43c4c9ce23735a5df Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 11:31:04 -0400 Subject: [PATCH 05/14] Add integration tests for region name normalization in LocationCache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 tests to LocationCacheTest using real Azure region names to verify that preferred regions and exclude regions work correctly with non-canonical input: - preferredRegions_lowercaseShouldMatchCanonical: 'west us 3' → West US 3 - preferredRegions_noSpacesShouldMatchCanonical: 'westus3' → West US 3 - preferredRegions_uppercaseShouldMatchCanonical: 'WEST US 3' → West US 3 - preferredRegions_duplicateAfterNormalizationShouldDedupe: 'westus3' + 'West US 3' deduped to single entry - excludeRegions_lowercaseNoSpacesShouldExclude: 'westus3' excludes West US 3 - excludeRegions_mixedCasingShouldExclude: 'EAST us' excludes East US - excludeRegions_requestLevelNoSpacesShouldExclude: request-level 'eastus' excludes East US All 39 LocationCacheTest unit tests pass (32 existing + 7 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../routing/LocationCacheTest.java | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) 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..a7d0f38f3d49 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,163 @@ 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 preferredRegions_duplicateAfterNormalizationShouldDedupe() { + // "westus3" and "West US 3" normalize to the same thing — should dedupe + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("westus3", "West US 3", "east us")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + // Should have 2 preferred (deduped West US 3) + 1 remaining + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + } + + @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); + } } From e668ec465d80d05c78026851a2303de50bc2747f Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 11:37:28 -0400 Subject: [PATCH 06/14] Add e2e tests for region normalization in ExcludeRegionTests Create a second CosmosClient with space-stripped preferred regions (e.g., 'westus3' instead of 'west us 3') and verify that routing and region exclusion work identically to canonical names. New tests: - nonCanonicalPreferredRegions_shouldRouteCorrectly: client with space-stripped preferred regions routes to correct first region (7 operation types via DataProvider) - nonCanonicalExcludeRegion_shouldSkipExcludedRegion: excluding with space-stripped name (e.g., 'westus3') correctly skips that region (7 operation types via DataProvider) - uppercaseExcludeRegion_shouldSkipExcludedRegion: excluding with UPPERCASE name correctly skips that region Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/azure/cosmos/ExcludeRegionTests.java | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) 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(); From a5897e9d257c7531b60fa17c2ee91888a5f7e0c8 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 11:49:28 -0400 Subject: [PATCH 07/14] Add e2e tests for region normalization in availability strategy and PPCB Add tests that create CosmosClients with space-stripped preferred regions (e.g., 'westus3' instead of 'West US 3') and verify correct routing. FaultInjectionWithAvailabilityStrategyTestsBase: - Add nonCanonicalWriteableRegions field (space-stripped from server names) - readAfterCreation_nonCanonicalPreferredRegions_shouldRouteCorrectly: creates client with space-stripped regions, reads with eager availability strategy, verifies first contacted region matches expected canonical name PerPartitionCircuitBreakerE2ETests: - nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly: creates client with space-stripped regions, performs create+read, verifies diagnostics show routing to correct first preferred region Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...tionWithAvailabilityStrategyTestsBase.java | 68 ++++++++++++++++++ .../PerPartitionCircuitBreakerE2ETests.java | 70 +++++++++++++++++++ 2 files changed, 138 insertions(+) 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..1667dc9d18a1 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().iterator().next()) + .isEqualTo(expectedFirstRegion); + + } finally { + if (asyncClient != null) { + asyncClient.close(); + } + } + } } From 6083eac82e692f9e6211d172f464d8d4afb90cc6 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 11:56:45 -0400 Subject: [PATCH 08/14] =?UTF-8?q?Remove=20dynamic=20region=20registration?= =?UTF-8?q?=20=E2=80=94=20pass=20unknown=20regions=20as-is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify RegionNameToRegionIdMap by removing the ConcurrentHashMap-backed dynamic registration tier. Unknown regions are returned as-is, which is sufficient because LocationCache's CaseInsensitiveMap + toLowerCase handles the matching for any region the server returns. - Remove DYNAMIC_NORMALIZED_TO_CANONICAL and registerRegionName() - Remove registerRegionName() call from LocationCache.addRoutingContexts() - Replace dynamic registration tests with passthrough assertion tests - 84/84 tests pass (44 normalization + 39 LocationCache + 1 consistency) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ionNameToRegionIdMapNormalizationTest.java | 23 ++---------- .../implementation/routing/LocationCache.java | 4 --- .../routing/RegionNameToRegionIdMap.java | 35 ++----------------- 3 files changed, 6 insertions(+), 56 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java index 7cdb42f968f2..dde9cc6787f7 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java @@ -100,26 +100,9 @@ public void shouldHandleBlankString() { } @Test(groups = "unit") - public void shouldNormalizeAfterDynamicRegistration() { - // Before registration: unknown region passes through as-is - String unknownSpaceStripped = "futureregion99"; - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName(unknownSpaceStripped)).isEqualTo(unknownSpaceStripped); - - // Simulate server returning this new region name - RegionNameToRegionIdMap.registerRegionName("Future Region 99"); - - // After registration: all variants normalize to canonical form - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("futureregion99")).isEqualTo("Future Region 99"); - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("future region 99")).isEqualTo("Future Region 99"); - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("FUTURE REGION 99")).isEqualTo("Future Region 99"); - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("FutureRegion99")).isEqualTo("Future Region 99"); + public void shouldPassthroughUnknownRegionsAsIs() { + // Unknown regions not in the static map should be returned as-is + assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("futureregion99")).isEqualTo("futureregion99"); assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("Future Region 99")).isEqualTo("Future Region 99"); } - - @Test(groups = "unit") - public void dynamicRegistrationShouldNotOverrideStaticEntries() { - // "West US" is in the static map — dynamic registration should not overwrite it - RegionNameToRegionIdMap.registerRegionName("west us"); // wrong casing - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("westus")).isEqualTo("West US"); - } } 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 8bedf1cc7c58..32a6fe5cb966 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 @@ -913,10 +913,6 @@ private void addRoutingContexts( if (!Strings.isNullOrEmpty(gatewayDbAccountLocation.getName())) { try { - // Register the canonical server name so future user inputs - // (e.g., "westus4") can be normalized even for new regions - RegionNameToRegionIdMap.registerRegionName(gatewayDbAccountLocation.getName()); - String location = gatewayDbAccountLocation.getName().toLowerCase(Locale.ROOT); URI endpoint = new URI(gatewayDbAccountLocation.getEndpoint().toLowerCase(Locale.ROOT)); 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 index e4a00c4cb734..3f0012f1f06b 100644 --- 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 @@ -9,7 +9,6 @@ import java.util.HashMap; import java.util.Locale; import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; /** * Single source of truth for Azure region name mappings in the Cosmos Java SDK. @@ -228,10 +227,6 @@ public static int getRegionId(String regionName) { // Static map: lowercase-no-spaces key → canonical display name private static final Map NORMALIZED_TO_CANONICAL; - // Dynamic map: populated from server-returned DatabaseAccountLocation names - // at runtime, so new regions not in the static list can still be normalized - private static final ConcurrentHashMap DYNAMIC_NORMALIZED_TO_CANONICAL = new ConcurrentHashMap<>(); - static { Map map = new HashMap<>(); @@ -275,9 +270,9 @@ public static int getRegionId(String regionName) { /** * Normalizes a region name to the canonical CosmosDB format. *

- * Strips spaces, lowercases, and looks up in both the static known-region map - * and the dynamic map (populated from server responses). If recognized, returns - * the canonical form (e.g., "West US 3"). If not recognized, returns the input as-is. + * 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 input as-is for forward compatibility. * * @param regionName the region name to normalize (any casing/spacing variant) * @return the canonical CosmosDB region name, or the original input if unrecognized @@ -294,33 +289,9 @@ public static String getCosmosDBRegionName(String regionName) { return canonical; } - canonical = DYNAMIC_NORMALIZED_TO_CANONICAL.get(normalized); - if (canonical != null) { - return canonical; - } - return regionName; } - /** - * Registers a canonical region name learned from a server response. - *

- * Called when processing {@code DatabaseAccountLocation} names from the account read response. - * This ensures that new Azure regions (not yet in the static list) can still be normalized - * correctly for subsequent preferred-region or exclude-region lookups. - * - * @param canonicalRegionName the canonical region name from the server (e.g., "West US 4") - */ - public static void registerRegionName(String canonicalRegionName) { - if (StringUtils.isEmpty(canonicalRegionName)) { - return; - } - String key = canonicalRegionName.toLowerCase(Locale.ROOT).replace(" ", ""); - if (!NORMALIZED_TO_CANONICAL.containsKey(key)) { - DYNAMIC_NORMALIZED_TO_CANONICAL.putIfAbsent(key, canonicalRegionName); - } - } - private static void addCanonicalMapping(Map map, String canonicalName) { String key = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); map.putIfAbsent(key, canonicalName); From 5b7cd2b7f4bedad574ac1a923ecc0d4ae2525085 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 12:21:03 -0400 Subject: [PATCH 09/14] =?UTF-8?q?Remove=20dedupe=20from=20setPreferredRegi?= =?UTF-8?q?ons=20=E2=80=94=20let=20customer=20misconfig=20be=20visible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplicate preferred regions after normalization (e.g., ['westus3', 'West US 3'] both becoming 'West US 3') are an obvious customer misconfiguration. The SDK should not silently mask this — let the duplicates pass through so the customer can see and fix their config. Also clarify code comments for the escape hatch behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../implementation/routing/LocationCacheTest.java | 12 ------------ .../cosmos/implementation/ConnectionPolicy.java | 10 +++++----- .../cosmos/implementation/routing/LocationCache.java | 3 ++- 3 files changed, 7 insertions(+), 18 deletions(-) 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 a7d0f38f3d49..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 @@ -1023,18 +1023,6 @@ public void preferredRegions_uppercaseShouldMatchCanonical() { assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(NorthEuropeEndpoint); } - @Test(groups = "unit") - public void preferredRegions_duplicateAfterNormalizationShouldDedupe() { - // "westus3" and "West US 3" normalize to the same thing — should dedupe - LocationCache locationCache = createCacheWithRealRegions( - Arrays.asList("westus3", "West US 3", "east us")); - - UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); - // Should have 2 preferred (deduped West US 3) + 1 remaining - assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); - assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); - } - @Test(groups = "unit") public void excludeRegions_lowercaseNoSpacesShouldExclude() { // Preferred regions in canonical form, exclude region with no spaces 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 631ca317285e..6fb83b912982 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 @@ -17,7 +17,6 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Collections; -import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.function.Supplier; @@ -493,14 +492,15 @@ public ConnectionPolicy setPreferredRegions(List preferredRegions) { return this; } - // Normalize each region to canonical form and dedupe (order-preserving) - LinkedHashSet deduped = new LinkedHashSet<>(); + // 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) { - deduped.add(RegionNameToRegionIdMap.getCosmosDBRegionName(region)); + normalized.add(RegionNameToRegionIdMap.getCosmosDBRegionName(region)); } } - this.preferredRegions = new ArrayList<>(deduped); + this.preferredRegions = normalized; return this; } 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 32a6fe5cb966..2152bf0c7a70 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,7 +345,8 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); - // Normalize user-configured exclude regions to canonical form for consistent comparison + // 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 = normalizeRegionNames(userConfiguredExcludeRegions); // exclude those regions which are user excluded first From d16d10173cef9bc155bebe4ce3c20e887b5dd44b Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 12:23:51 -0400 Subject: [PATCH 10/14] Fix stale javadoc referencing dynamic registration + add CHANGELOG entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/cosmos/azure-cosmos/CHANGELOG.md | 1 + .../implementation/routing/RegionNameToRegionIdMap.java | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) 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/routing/RegionNameToRegionIdMap.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMap.java index 3f0012f1f06b..05964b2781c1 100644 --- 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 @@ -13,15 +13,14 @@ /** * Single source of truth for Azure region name mappings in the Cosmos Java SDK. *

- * Provides three capabilities: + * Provides two capabilities: *

    *
  1. Region ID mapping — canonical name ↔ numeric ID for session token region-level progress tracking. * Must stay in sync with * RegionToIdMap.cs.
  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").
  4. - *
  5. Dynamic registration — learns new canonical names from server responses at runtime, so regions - * not yet in the static list can still be normalized after the first account read.
  6. + * to the canonical CosmosDB format ("West US 3"). Unknown regions not in the static map are + * returned as-is. *
*/ public class RegionNameToRegionIdMap { From b6a56c0a4f9810e213f2c7c79f7e3d1c18b0c927 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 12:41:00 -0400 Subject: [PATCH 11/14] Sync canonical region list with LocationNames.cs Add 10 regions from the authoritative LocationNames.cs that were missing from the normalization map: East US SLV, Southeast US, Southwest US, South Central US 2, Southeast US 3, Southeast US 5, Northeast US 5, India South Central, Southeast Asia 3, West Central US FRE. Region ID mappings remain a subset (only regions with assigned IDs from RegionToIdMap.cs). The normalization map is the superset sourced from LocationNames.cs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../routing/RegionNameToRegionIdMap.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 index 05964b2781c1..23d52876afea 100644 --- 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 @@ -234,7 +234,7 @@ public static int getRegionId(String regionName) { addCanonicalMapping(map, canonicalName); } - // Additional regions that don't have IDs yet (from .NET SDK Regions.cs / Azure portal) + // Additional regions from LocationNames.cs that don't have IDs yet addCanonicalMapping(map, "West US 3"); addCanonicalMapping(map, "East US 3"); addCanonicalMapping(map, "Brazil Southeast"); @@ -262,6 +262,16 @@ public static int getRegionId(String regionName) { addCanonicalMapping(map, "Saudi Arabia East"); addCanonicalMapping(map, "China East 3"); addCanonicalMapping(map, "China North 3"); + addCanonicalMapping(map, "East US SLV"); + addCanonicalMapping(map, "Southeast US"); + addCanonicalMapping(map, "Southwest US"); + addCanonicalMapping(map, "South Central US 2"); + addCanonicalMapping(map, "Southeast US 3"); + addCanonicalMapping(map, "Southeast US 5"); + addCanonicalMapping(map, "Northeast US 5"); + addCanonicalMapping(map, "India South Central"); + addCanonicalMapping(map, "Southeast Asia 3"); + addCanonicalMapping(map, "West Central US FRE"); NORMALIZED_TO_CANONICAL = Collections.unmodifiableMap(map); } From 5005ced20f292938e24c27e00fcc73f829d83bdf Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 12:58:29 -0400 Subject: [PATCH 12/14] Sync region ID map with authoritative Settings.xml regionToIdMapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the previous RegionToIdMap.cs-based ID map with the complete regionToIdMapping from Settings.xml (IDs 1-124). This is the authoritative source for region name ↔ ID mappings used for session token region-level progress tracking. - Add 44 new region IDs (74-124): Brazil Southeast, West US 3, Qatar Central, Italy North, East US 3, Saudi Arabia East, etc. - Remove separate 'additional canonical names' block — all canonical names now derive from the ID map since Settings.xml is the superset. - Remove 'Greece Central' which was not in any authoritative source. - Update javadoc and code comments to reference Settings.xml as the authoritative source instead of RegionToIdMap.cs. - 83/83 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../routing/RegionNameToRegionIdMap.java | 146 ++++++++++++------ 1 file changed, 102 insertions(+), 44 deletions(-) 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 index 23d52876afea..9c20880527dd 100644 --- 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 @@ -16,8 +16,8 @@ * Provides two capabilities: *
    *
  1. Region ID mapping — canonical name ↔ numeric ID for session token region-level progress tracking. - * Must stay in sync with - * RegionToIdMap.cs.
  2. + * Must stay in sync with the authoritative regionToIdMapping in + * Settings.xml. *
  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. @@ -26,7 +26,11 @@ public class RegionNameToRegionIdMap { // ======================================================================== - // Region ID mappings (synced with backend RegionToIdMap.cs) + // 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 = new HashMap() { @@ -104,6 +108,39 @@ public class RegionNameToRegionIdMap { 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); @@ -111,6 +148,17 @@ public class RegionNameToRegionIdMap { 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); } }; @@ -189,6 +237,39 @@ public class RegionNameToRegionIdMap { put(71, "swedencentral"); put(72, "swedensouth"); put(73, "koreasouth2"); + put(74, "brazilsoutheast"); + put(75, "brazilnortheast"); + put(76, "chilecentral"); + put(77, "westus3"); + put(78, "jioindiawest"); + put(79, "jioindiacentral"); + put(80, "qatarcentral"); + put(81, "israelcentral"); + put(82, "mexicocentral"); + put(83, "spaincentral"); + put(84, "taiwannorth"); + put(85, "singaporegov"); + put(86, "polandcentral"); + put(87, "chilenorthcentral"); + put(88, "usseccentral"); + put(89, "malaysiawest"); + put(90, "newzealandnorth"); + put(91, "italynorth"); + put(92, "eastusslv"); + put(93, "chinanorth3"); + put(94, "chinaeast3"); + put(95, "austriaeast"); + put(96, "taiwannorthwest"); + put(97, "belgiumcentral"); + put(98, "malaysiasouth"); + put(99, "indiasouthcentral"); + put(100, "indonesiacentral"); + put(101, "finlandcentral"); + put(102, "israelnorthwest"); + put(103, "denmarkeast"); + put(104, "southeastus"); + put(105, "ocave"); + put(106, "arlem"); put(107, "bleufrancecentral"); put(108, "bleufrancesouth"); put(109, "deloscloudgermanycentral"); @@ -196,6 +277,17 @@ public class RegionNameToRegionIdMap { put(111, "singaporecentral"); put(112, "singaporenorth"); put(113, "ussecwestcentral"); + put(114, "southcentralus2"); + put(115, "southwestus"); + put(116, "eastus3"); + put(117, "southeastus3"); + put(118, "usnatnorth"); + put(119, "southeastus5"); + put(120, "saudiarabiaeast"); + put(121, "westcentralusfre"); + put(122, "northeastus5"); + put(123, "southeastasia3"); + put(124, "northeurope3"); } }; @@ -220,7 +312,10 @@ public static int getRegionId(String regionName) { } // ======================================================================== - // Region name normalization (any variant → canonical CosmosDB format) + // 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 @@ -229,50 +324,13 @@ public static int getRegionId(String regionName) { static { Map map = new HashMap<>(); - // Seed from the ID map (all backend-known regions) + // 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); } - // Additional regions from LocationNames.cs that don't have IDs yet - addCanonicalMapping(map, "West US 3"); - addCanonicalMapping(map, "East US 3"); - addCanonicalMapping(map, "Brazil Southeast"); - addCanonicalMapping(map, "Mexico Central"); - addCanonicalMapping(map, "Chile Central"); - addCanonicalMapping(map, "Poland Central"); - addCanonicalMapping(map, "Italy North"); - addCanonicalMapping(map, "Spain Central"); - addCanonicalMapping(map, "Austria East"); - addCanonicalMapping(map, "Belgium Central"); - addCanonicalMapping(map, "Denmark East"); - addCanonicalMapping(map, "Finland Central"); - addCanonicalMapping(map, "Greece Central"); - addCanonicalMapping(map, "Jio India Central"); - addCanonicalMapping(map, "Jio India West"); - addCanonicalMapping(map, "New Zealand North"); - addCanonicalMapping(map, "Indonesia Central"); - addCanonicalMapping(map, "Malaysia South"); - addCanonicalMapping(map, "Malaysia West"); - addCanonicalMapping(map, "Taiwan North"); - addCanonicalMapping(map, "Taiwan Northwest"); - addCanonicalMapping(map, "Qatar Central"); - addCanonicalMapping(map, "Israel Central"); - addCanonicalMapping(map, "Israel Northwest"); - addCanonicalMapping(map, "Saudi Arabia East"); - addCanonicalMapping(map, "China East 3"); - addCanonicalMapping(map, "China North 3"); - addCanonicalMapping(map, "East US SLV"); - addCanonicalMapping(map, "Southeast US"); - addCanonicalMapping(map, "Southwest US"); - addCanonicalMapping(map, "South Central US 2"); - addCanonicalMapping(map, "Southeast US 3"); - addCanonicalMapping(map, "Southeast US 5"); - addCanonicalMapping(map, "Northeast US 5"); - addCanonicalMapping(map, "India South Central"); - addCanonicalMapping(map, "Southeast Asia 3"); - addCanonicalMapping(map, "West Central US FRE"); - NORMALIZED_TO_CANONICAL = Collections.unmodifiableMap(map); } From 06351d1c3e81a2a5cacc1a4cd601d3a5783d1fcb Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 13:10:22 -0400 Subject: [PATCH 13/14] =?UTF-8?q?Rename=20RegionNameToRegionIdMap=20?= =?UTF-8?q?=E2=86=92=20RegionUtils,=20move=20helpers=20out=20of=20Location?= =?UTF-8?q?Cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename class to RegionUtils — better reflects its dual role (ID mapping + region name normalization). - Move normalizeRegionNames() and containsRegionIgnoreCase() from LocationCache private helpers into RegionUtils as public static methods. - Rename all test files to match: RegionUtilsNormalizationTest, RegionUtilsTests. - Update all references across ConnectionPolicy, LocationCache, PartitionScopedRegionLevelProgress, RxDocumentClientImpl. - 83/83 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...nIdMapTests.java => RegionUtilsTests.java} | 16 +++---- ...java => RegionUtilsNormalizationTest.java} | 20 ++++----- .../implementation/ConnectionPolicy.java | 4 +- .../PartitionScopedRegionLevelProgress.java | 8 ++-- .../implementation/RxDocumentClientImpl.java | 4 +- .../implementation/routing/LocationCache.java | 32 ++----------- ...ameToRegionIdMap.java => RegionUtils.java} | 45 ++++++++++++++++++- 7 files changed, 73 insertions(+), 56 deletions(-) rename sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/{RegionNameToRegionIdMapTests.java => RegionUtilsTests.java} (57%) rename sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/{RegionNameToRegionIdMapNormalizationTest.java => RegionUtilsNormalizationTest.java} (79%) rename sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/{RegionNameToRegionIdMap.java => RegionUtils.java} (89%) 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/RegionNameToRegionIdMapNormalizationTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsNormalizationTest.java similarity index 79% rename from sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java rename to sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsNormalizationTest.java index dde9cc6787f7..d8162f838642 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMapNormalizationTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsNormalizationTest.java @@ -9,9 +9,9 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Tests for {@link RegionNameToRegionIdMap} + * Tests for {@link RegionUtils} */ -public class RegionNameToRegionIdMapNormalizationTest { +public class RegionUtilsNormalizationTest { @DataProvider(name = "regionNameVariants") public Object[][] regionNameVariants() { @@ -76,33 +76,33 @@ public Object[][] regionNameVariants() { @Test(groups = "unit", dataProvider = "regionNameVariants") public void shouldNormalizeRegionNameVariants(String input, String expectedCanonical) { - String result = RegionNameToRegionIdMap.getCosmosDBRegionName(input); + String result = RegionUtils.getCosmosDBRegionName(input); assertThat(result).isEqualTo(expectedCanonical); } @Test(groups = "unit") public void shouldPassthroughUnknownRegions() { // Unknown regions should be returned as-is for forward compatibility - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); + assertThat(RegionUtils.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); + assertThat(RegionUtils.getCosmosDBRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); } @Test(groups = "unit") public void shouldHandleNullAndEmpty() { - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName(null)).isNull(); - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("")).isEqualTo(""); + assertThat(RegionUtils.getCosmosDBRegionName(null)).isNull(); + assertThat(RegionUtils.getCosmosDBRegionName("")).isEqualTo(""); } @Test(groups = "unit") public void shouldHandleBlankString() { // Blank strings (only spaces) → stripped to "" → not in map → returned as-is - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName(" ")).isEqualTo(" "); + assertThat(RegionUtils.getCosmosDBRegionName(" ")).isEqualTo(" "); } @Test(groups = "unit") public void shouldPassthroughUnknownRegionsAsIs() { // Unknown regions not in the static map should be returned as-is - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("futureregion99")).isEqualTo("futureregion99"); - assertThat(RegionNameToRegionIdMap.getCosmosDBRegionName("Future Region 99")).isEqualTo("Future Region 99"); + assertThat(RegionUtils.getCosmosDBRegionName("futureregion99")).isEqualTo("futureregion99"); + assertThat(RegionUtils.getCosmosDBRegionName("Future Region 99")).isEqualTo("Future Region 99"); } } 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 6fb83b912982..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,7 @@ import com.azure.cosmos.Http2ConnectionConfig; import com.azure.cosmos.ThrottlingRetryOptions; -import com.azure.cosmos.implementation.routing.RegionNameToRegionIdMap; +import com.azure.cosmos.implementation.routing.RegionUtils; import java.time.Duration; import java.util.ArrayList; @@ -497,7 +497,7 @@ public ConnectionPolicy setPreferredRegions(List preferredRegions) { List normalized = new ArrayList<>(preferredRegions.size()); for (String region : preferredRegions) { if (region != null) { - normalized.add(RegionNameToRegionIdMap.getCosmosDBRegionName(region)); + normalized.add(RegionUtils.getCosmosDBRegionName(region)); } } this.preferredRegions = normalized; 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 2152bf0c7a70..ee2a4cb959f7 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 @@ -347,7 +347,7 @@ private UnmodifiableList getApplicableRegionRoutingConte // 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 = normalizeRegionNames(userConfiguredExcludeRegions); + List normalizedUserExcludeRegions = RegionUtils.normalizeRegionNames(userConfiguredExcludeRegions); // exclude those regions which are user excluded first for (RegionalRoutingContext endpoint : regionalRoutingContexts) { @@ -503,7 +503,7 @@ private UnmodifiableList reevaluate( Utils.ValueHolder regionalRoutingContextValueHolder = new Utils.ValueHolder<>(null); if (Utils.tryGetValue(regionalRoutingContextsByRegionName, internalExcludeRegion, regionalRoutingContextValueHolder)) { - if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) && !containsRegionIgnoreCase(userConfiguredExcludeRegions, internalExcludeRegion)) { + if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) && !RegionUtils.containsRegionIgnoreCase(userConfiguredExcludeRegions, internalExcludeRegion)) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -1051,32 +1051,6 @@ private static boolean isExcludedRegionsSupplierConfigured(Supplier 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(RegionNameToRegionIdMap.getCosmosDBRegionName(region)); - } - } - return normalized; - } - - private static boolean containsRegionIgnoreCase(List regions, String target) { - if (regions == null || regions.isEmpty()) { - return false; - } - String normalizedTarget = RegionNameToRegionIdMap.getCosmosDBRegionName(target); - for (String region : regions) { - if (RegionNameToRegionIdMap.getCosmosDBRegionName(region).equalsIgnoreCase(normalizedTarget)) { - return true; - } - } - return false; - } - static class DatabaseAccountLocationsInfo { private UnmodifiableList writeRegionalRoutingContexts; private UnmodifiableList readRegionalRoutingContexts; @@ -1096,7 +1070,7 @@ static class DatabaseAccountLocationsInfo { public DatabaseAccountLocationsInfo(List preferredLocations, RegionalRoutingContext defaultRoutingContext) { - this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> RegionNameToRegionIdMap.getCosmosDBRegionName(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/RegionUtils.java similarity index 89% rename from sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMap.java rename to sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionUtils.java index 9c20880527dd..48831759b85d 100644 --- 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/RegionUtils.java @@ -5,8 +5,10 @@ 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; @@ -23,7 +25,7 @@ * returned as-is. *
*/ -public class RegionNameToRegionIdMap { +public class RegionUtils { // ======================================================================== // Region ID mappings — used only for session token region-level progress @@ -359,6 +361,47 @@ public static String getCosmosDBRegionName(String regionName) { return regionName; } + /** + * 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 (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); From 71d9f8e5eb052966f89d931517477b76369fe84d Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 16:19:22 -0400 Subject: [PATCH 14/14] Address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix comment indentation in LocationCache (line 349) - Make REGION_NAME_TO_REGION_ID_MAPPINGS unmodifiable to prevent accidental mutation after initialization - Derive REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS programmatically from the forward map — eliminates ~130 lines of manual duplication - Return normalized form (lowercase, no spaces) for unknown regions instead of as-is — ensures space-stripped unknown regions match after LocationCache toLowerCase() (e.g., 'futureregion' matches 'future region') - Add null guard in containsRegionIgnoreCase to prevent NPE on null list elements - Fix PPCB test to use contains() instead of iterator().next() to avoid flaky assertion on Set iteration order - Add unit tests for normalizeRegionNames() and containsRegionIgnoreCase() (9 new tests covering normalization, null/empty, null elements, matches) - Update unknown-region tests to expect normalized form - 90/90 tests pass (51 normalization + 38 LocationCache + 1 consistency) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../PerPartitionCircuitBreakerE2ETests.java | 4 +- .../routing/RegionUtilsNormalizationTest.java | 72 +++++++- .../implementation/routing/LocationCache.java | 2 +- .../implementation/routing/RegionUtils.java | 155 ++---------------- 4 files changed, 83 insertions(+), 150 deletions(-) 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 1667dc9d18a1..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 @@ -5328,8 +5328,8 @@ public void nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly() { // 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().iterator().next()) - .isEqualTo(expectedFirstRegion); + assertThat(diagnosticsContext.getContactedRegionNames()) + .contains(expectedFirstRegion); } finally { if (asyncClient != null) { 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 index d8162f838642..addd68b2b8d8 100644 --- 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 @@ -6,6 +6,9 @@ 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; /** @@ -81,10 +84,10 @@ public void shouldNormalizeRegionNameVariants(String input, String expectedCanon } @Test(groups = "unit") - public void shouldPassthroughUnknownRegions() { - // Unknown regions should be returned as-is for forward compatibility - assertThat(RegionUtils.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); - assertThat(RegionUtils.getCosmosDBRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); + 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") @@ -95,14 +98,65 @@ public void shouldHandleNullAndEmpty() { @Test(groups = "unit") public void shouldHandleBlankString() { - // Blank strings (only spaces) → stripped to "" → not in map → returned as-is - assertThat(RegionUtils.getCosmosDBRegionName(" ")).isEqualTo(" "); + // Blank strings (only spaces) → stripped to "" → normalized to "" + assertThat(RegionUtils.getCosmosDBRegionName(" ")).isEqualTo(""); } @Test(groups = "unit") - public void shouldPassthroughUnknownRegionsAsIs() { - // Unknown regions not in the static map should be returned as-is + 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("Future Region 99"); + 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/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 ee2a4cb959f7..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 @@ -346,7 +346,7 @@ private UnmodifiableList getApplicableRegionRoutingConte 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. + // 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 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 index 48831759b85d..6aa5363473ec 100644 --- 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 @@ -35,7 +35,7 @@ public class RegionUtils { // This is a SUBSET of all known regions — only regions with assigned IDs. // ======================================================================== - public static final Map REGION_NAME_TO_REGION_ID_MAPPINGS = new HashMap() { + public static final Map REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(new HashMap() { { put("East US", 1); put("East US 2", 2); @@ -162,147 +162,23 @@ public class RegionUtils { put("Southeast Asia 3", 123); put("North Europe 3", 124); } - }; + }); - public static final Map REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS = new HashMap() { - { - put(1, "eastus"); - put(2, "eastus2"); - put(3, "centralus"); - put(4, "northcentralus"); - put(5, "southcentralus"); - put(6, "westcentralus"); - put(7, "westus"); - put(8, "westus2"); - put(9, "canadaeast"); - put(10, "canadacentral"); - put(11, "brazilsouth"); - put(12, "northeurope"); - put(13, "westeurope"); - put(14, "francecentral"); - put(15, "francesouth"); - put(16, "ukwest"); - put(17, "uksouth"); - put(18, "germanycentral"); - put(19, "germanynortheast"); - put(20, "germanynorth"); - put(21, "germanywestcentral"); - put(22, "switzerlandnorth"); - put(23, "switzerlandwest"); - put(24, "southeastasia"); - put(25, "eastasia"); - put(26, "australiaeast"); - put(27, "australiasoutheast"); - put(28, "australiacentral"); - put(29, "australiacentral2"); - put(30, "chinaeast"); - put(31, "chinanorth"); - put(32, "centralindia"); - put(33, "westindia"); - put(34, "southindia"); - put(35, "japaneast"); - put(36, "japanwest"); - put(37, "koreacentral"); - put(38, "koreasouth"); - put(39, "usgovvirginia"); - put(40, "usgoviowa"); - put(41, "usgovarizona"); - put(42, "usgovtexas"); - put(43, "usdodeast"); - put(44, "usdodcentral"); - put(45, "usseceast"); - put(46, "ussecwest"); - put(47, "southafricawest"); - put(48, "southafricanorth"); - put(49, "uaecentral"); - put(50, "uaenorth"); - put(51, "centraluseuap"); - put(52, "eastus2euap"); - put(53, "northeurope2"); - put(54, "easteurope"); - put(55, "apacsoutheast2"); - put(56, "uksouth2"); - put(57, "uknorth"); - put(58, "eastusstg"); - put(59, "southcentralusstg"); - put(60, "norwayeast"); - put(61, "norwaywest"); - put(62, "usgovwyoming"); - put(63, "usdodsouthwest"); - put(64, "usdodwestcentral"); - put(65, "usdodsouthcentral"); - put(66, "chinaeast2"); - put(67, "chinanorth2"); - put(68, "usnateast"); - put(69, "usnatwest"); - put(70, "chinanorth10"); - put(71, "swedencentral"); - put(72, "swedensouth"); - put(73, "koreasouth2"); - put(74, "brazilsoutheast"); - put(75, "brazilnortheast"); - put(76, "chilecentral"); - put(77, "westus3"); - put(78, "jioindiawest"); - put(79, "jioindiacentral"); - put(80, "qatarcentral"); - put(81, "israelcentral"); - put(82, "mexicocentral"); - put(83, "spaincentral"); - put(84, "taiwannorth"); - put(85, "singaporegov"); - put(86, "polandcentral"); - put(87, "chilenorthcentral"); - put(88, "usseccentral"); - put(89, "malaysiawest"); - put(90, "newzealandnorth"); - put(91, "italynorth"); - put(92, "eastusslv"); - put(93, "chinanorth3"); - put(94, "chinaeast3"); - put(95, "austriaeast"); - put(96, "taiwannorthwest"); - put(97, "belgiumcentral"); - put(98, "malaysiasouth"); - put(99, "indiasouthcentral"); - put(100, "indonesiacentral"); - put(101, "finlandcentral"); - put(102, "israelnorthwest"); - put(103, "denmarkeast"); - put(104, "southeastus"); - put(105, "ocave"); - put(106, "arlem"); - put(107, "bleufrancecentral"); - put(108, "bleufrancesouth"); - put(109, "deloscloudgermanycentral"); - put(110, "deloscloudgermanynorth"); - put(111, "singaporecentral"); - put(112, "singaporenorth"); - put(113, "ussecwestcentral"); - put(114, "southcentralus2"); - put(115, "southwestus"); - put(116, "eastus3"); - put(117, "southeastus3"); - put(118, "usnatnorth"); - put(119, "southeastus5"); - put(120, "saudiarabiaeast"); - put(121, "westcentralusfre"); - put(122, "northeastus5"); - put(123, "southeastasia3"); - put(124, "northeurope3"); - } - }; + public static final Map REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS; public static final Map NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS; static { - // Build normalized→ID map from REGION_NAME_TO_REGION_ID_MAPPINGS - Map normalizedMap = new HashMap<>(); + // 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(" ", ""); - normalizedMap.put(normalized, entry.getValue()); + normalizedToId.put(normalized, entry.getValue()); + idToNormalized.putIfAbsent(entry.getValue(), normalized); } - NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(normalizedMap); + 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) { @@ -341,10 +217,13 @@ public static int getRegionId(String regionName) { *

* 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 input as-is for forward compatibility. + * 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 original input if unrecognized + * @return the canonical CosmosDB region name, or the lowercase space-stripped + * form if unrecognized */ public static String getCosmosDBRegionName(String regionName) { if (StringUtils.isEmpty(regionName)) { @@ -358,7 +237,7 @@ public static String getCosmosDBRegionName(String regionName) { return canonical; } - return regionName; + return normalized; } /** @@ -395,7 +274,7 @@ public static boolean containsRegionIgnoreCase(List regions, String targ } String normalizedTarget = getCosmosDBRegionName(target); for (String region : regions) { - if (getCosmosDBRegionName(region).equalsIgnoreCase(normalizedTarget)) { + if (region != null && getCosmosDBRegionName(region).equalsIgnoreCase(normalizedTarget)) { return true; } }