From cbde001cd714904e58e7f3654199b7e24b1b42c9 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 7 May 2026 09:58:43 -0400 Subject: [PATCH 01/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] 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/58] =?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/58] =?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/58] 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/58] 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/58] 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/58] =?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/58] 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; } } From 0a2c882c8d25d2f338e9b02f207c3d6e84cf95a0 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 10:35:15 -0400 Subject: [PATCH 15/58] Address PR review: preserve customer input in public API, merge static blocks, make RegionUtils final --- .../routing/RegionUtilsNormalizationTest.java | 14 ++--- .../implementation/ConnectionPolicy.java | 13 ++-- .../implementation/routing/RegionUtils.java | 61 ++++++++----------- 3 files changed, 35 insertions(+), 53 deletions(-) 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 addd68b2b8d8..e24742d7104e 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 @@ -85,9 +85,9 @@ public void shouldNormalizeRegionNameVariants(String input, String expectedCanon @Test(groups = "unit") public void shouldNormalizeUnknownRegions() { - // Unknown regions should be returned in normalized form (lowercase, no spaces) - assertThat(RegionUtils.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("mycustomregion"); - assertThat(RegionUtils.getCosmosDBRegionName("FutureRegion42")).isEqualTo("futureregion42"); + // Unknown regions should be returned as-is (customer input preserved) + assertThat(RegionUtils.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); + assertThat(RegionUtils.getCosmosDBRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); } @Test(groups = "unit") @@ -103,11 +103,11 @@ public void shouldHandleBlankString() { } @Test(groups = "unit") - public void unknownRegionVariantsShouldCollapse() { - // Unknown regions: different variants should collapse to the same normalized form + public void unknownRegionVariantsShouldBeReturnedAsIs() { + // Unknown regions: returned as customer-passed string (no transformation) assertThat(RegionUtils.getCosmosDBRegionName("futureregion99")).isEqualTo("futureregion99"); - assertThat(RegionUtils.getCosmosDBRegionName("Future Region 99")).isEqualTo("futureregion99"); - assertThat(RegionUtils.getCosmosDBRegionName("FUTURE REGION 99")).isEqualTo("futureregion99"); + assertThat(RegionUtils.getCosmosDBRegionName("Future Region 99")).isEqualTo("Future Region 99"); + 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 986fc5b3df2a..af9b53bb51df 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 @@ -492,15 +492,10 @@ public ConnectionPolicy setPreferredRegions(List preferredRegions) { return this; } - // Normalize each region to canonical CosmosDB form (e.g., "westus3" → "West US 3"). - // Unknown regions not in the static map are passed through as-is. - List normalized = new ArrayList<>(preferredRegions.size()); - for (String region : preferredRegions) { - if (region != null) { - normalized.add(RegionUtils.getCosmosDBRegionName(region)); - } - } - this.preferredRegions = normalized; + // Store the customer-supplied list as-is. + // Public API (getPreferredRegions) and CosmosDiagnostics should reflect customer input. + // Normalization to canonical CosmosDB form happens internally in LocationCache. + this.preferredRegions = new ArrayList<>(preferredRegions); return this; } 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 6aa5363473ec..dc913bac6969 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 @@ -25,7 +25,7 @@ * returned as-is. * */ -public class RegionUtils { +public final class RegionUtils { // ======================================================================== // Region ID mappings — used only for session token region-level progress @@ -168,17 +168,30 @@ public class RegionUtils { public static final Map NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS; + // Static map: lowercase-no-spaces key → canonical display name + private static final Map NORMALIZED_TO_CANONICAL; + static { - // Derive both maps programmatically from REGION_NAME_TO_REGION_ID_MAPPINGS + // Derive all maps programmatically from REGION_NAME_TO_REGION_ID_MAPPINGS Map idToNormalized = new HashMap<>(); Map normalizedToId = new HashMap<>(); + Map normalizedToCanonical = new HashMap<>(); + for (Map.Entry entry : REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { - String normalized = entry.getKey().toLowerCase(Locale.ROOT).replace(" ", ""); + String canonicalName = entry.getKey(); + String normalized = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); + normalizedToId.put(normalized, entry.getValue()); idToNormalized.putIfAbsent(entry.getValue(), normalized); + normalizedToCanonical.putIfAbsent(normalized, canonicalName); } + NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(normalizedToId); REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS = Collections.unmodifiableMap(idToNormalized); + NORMALIZED_TO_CANONICAL = Collections.unmodifiableMap(normalizedToCanonical); + } + + private RegionUtils() { } public static String getRegionName(int regionId) { @@ -189,41 +202,18 @@ public static int getRegionId(String regionName) { return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(regionName, -1); } - // ======================================================================== - // Region name normalization — canonical names derived from the ID map - // (sourced from Settings.xml regionToIdMapping). Used for normalizing - // user-supplied preferred regions and excluded regions to the canonical - // CosmosDB format. Unknown regions not in this map are passed through as-is. - // ======================================================================== - - // Static map: lowercase-no-spaces key → canonical display name - private static final Map NORMALIZED_TO_CANONICAL; - - static { - Map map = new HashMap<>(); - - // Seed from the ID map — Settings.xml is the authoritative source for all - // canonical region names. Every region with an assigned ID is automatically - // included in the normalization map. - for (String canonicalName : REGION_NAME_TO_REGION_ID_MAPPINGS.keySet()) { - addCanonicalMapping(map, canonicalName); - } - - NORMALIZED_TO_CANONICAL = Collections.unmodifiableMap(map); - } - /** * Normalizes a region name to the canonical CosmosDB format. *

* Strips spaces, lowercases, and looks up in the static known-region map. * If recognized, returns the canonical form (e.g., "West US 3"). - * If not recognized, returns the normalized form (lowercase, no spaces) - * for forward compatibility — this ensures unknown regions still match - * after LocationCache applies toLowerCase() to server-returned names. + * If not recognized, returns the customer-passed string as-is + * for forward compatibility — downstream consumers in LocationCache + * apply toLowerCase() or equalsIgnoreCase() as needed. * * @param regionName the region name to normalize (any casing/spacing variant) - * @return the canonical CosmosDB region name, or the lowercase space-stripped - * form if unrecognized + * @return the canonical CosmosDB region name, or the customer-passed + * string as-is if unrecognized */ public static String getCosmosDBRegionName(String regionName) { if (StringUtils.isEmpty(regionName)) { @@ -237,7 +227,9 @@ public static String getCosmosDBRegionName(String regionName) { return canonical; } - return normalized; + // Unknown region — return customer-passed string as-is. + // Downstream consumers apply toLowerCase() or equalsIgnoreCase() as needed. + return regionName; } /** @@ -280,9 +272,4 @@ public static boolean containsRegionIgnoreCase(List regions, String targ } return false; } - - private static void addCanonicalMapping(Map map, String canonicalName) { - String key = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); - map.putIfAbsent(key, canonicalName); - } } From 4c0281287e1c7866d8e383fa6f28057b1f100ee2 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 10:57:52 -0400 Subject: [PATCH 16/58] Push normalization into getRegionId with fast-path, restore region lookup cache --- .../PartitionScopedRegionLevelProgress.java | 1 - .../cosmos/implementation/RxDocumentClientImpl.java | 4 +--- .../cosmos/implementation/routing/RegionUtils.java | 12 +++++++++++- 3 files changed, 12 insertions(+), 5 deletions(-) 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 d19e2b88abb3..4defe74dbe2e 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 @@ -34,7 +34,6 @@ public class PartitionScopedRegionLevelProgress { public final static String GLOBAL_PROGRESS_KEY = "global"; - public PartitionScopedRegionLevelProgress() { this.partitionKeyRangeIdToRegionLevelProgress = new ConcurrentHashMap<>(); this.normalizedRegionLookupMap = new ConcurrentHashMap<>(); 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 9a66f048b725..6746b6e6466d 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 @@ -833,9 +833,7 @@ private boolean isRegionScopingOfSessionTokensPossible(DatabaseAccount databaseA while (readableLocationsIterator.hasNext()) { DatabaseAccountLocation readableLocation = readableLocationsIterator.next(); - String normalizedReadableRegion = readableLocation.getName().toLowerCase(Locale.ROOT).trim().replace(" ", ""); - - if (RegionUtils.getRegionId(normalizedReadableRegion) == -1) { + if (RegionUtils.getRegionId(readableLocation.getName()) == -1) { return false; } } 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 dc913bac6969..d486405f74d5 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 @@ -199,7 +199,17 @@ public static String getRegionName(int regionId) { } public static int getRegionId(String regionName) { - return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(regionName, -1); + if (StringUtils.isEmpty(regionName)) { + return -1; + } + // Fast path: try raw key first (avoids string allocation for already-normalized input) + int id = NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(regionName, -1); + if (id != -1) { + return id; + } + // Slow path: normalize and retry + String normalized = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(normalized, -1); } /** From 659717b62d4c923b2c48f48d97a3a1fa1a267fb6 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 11:33:13 -0400 Subject: [PATCH 17/58] Add region name normalization cache for exclude regions in LocationCache, revert CosmosExcludedRegions --- .../implementation/routing/LocationCache.java | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) 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 c613cf77935b..9b59e6a0a252 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 @@ -51,6 +51,7 @@ public class LocationCache { private final Object lockObject; private final Duration unavailableLocationsExpirationTime; private final ConcurrentHashMap locationUnavailabilityInfoByEndpoint; + private final ConcurrentHashMap normalizedRegionNameCache; private final ConnectionPolicy connectionPolicy; private DatabaseAccountLocationsInfo locationInfo; @@ -76,6 +77,7 @@ public LocationCache( this.lockObject = new Object(); this.locationUnavailabilityInfoByEndpoint = new ConcurrentHashMap<>(); + this.normalizedRegionNameCache = new ConcurrentHashMap<>(); this.lastCacheUpdateTimestamp = Instant.MIN; this.enableMultipleWriteLocations = false; @@ -345,9 +347,8 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); - // Normalize user-configured exclude regions to canonical form for consistent comparison. - // Unknown regions not in the static map are passed through as-is. - List normalizedUserExcludeRegions = RegionUtils.normalizeRegionNames(userConfiguredExcludeRegions); + // Normalize user-configured exclude regions using cache to avoid per-request allocation. + List normalizedUserExcludeRegions = this.getNormalizedExcludeRegions(userConfiguredExcludeRegions); // exclude those regions which are user excluded first for (RegionalRoutingContext endpoint : regionalRoutingContexts) { @@ -522,6 +523,19 @@ private boolean isExcludeRegionsConfigured(List excludedRegionsOnRequest return isExcludedRegionsConfiguredOnRequest || isExcludedRegionsConfiguredOnClient; } + private List getNormalizedExcludeRegions(List excludeRegions) { + if (excludeRegions == null || excludeRegions.isEmpty()) { + return Collections.emptyList(); + } + List normalized = new ArrayList<>(excludeRegions.size()); + for (String region : excludeRegions) { + if (region != null) { + normalized.add(this.normalizedRegionNameCache.computeIfAbsent(region, RegionUtils::getCosmosDBRegionName)); + } + } + return normalized; + } + public RegionalRoutingContext resolveFaultInjectionEndpoint(String region, boolean writeOnly) { Utils.ValueHolder endpointValueHolder = new Utils.ValueHolder<>(); if (writeOnly) { From 17cd14bf11307b1c65bc7b77e4a1a6c8eaed756f Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 11:52:34 -0400 Subject: [PATCH 18/58] Fix blank string test to match as-is fallback behavior --- .../implementation/routing/RegionUtilsNormalizationTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 e24742d7104e..604095262915 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 @@ -98,8 +98,8 @@ public void shouldHandleNullAndEmpty() { @Test(groups = "unit") public void shouldHandleBlankString() { - // Blank strings (only spaces) → stripped to "" → normalized to "" - assertThat(RegionUtils.getCosmosDBRegionName(" ")).isEqualTo(""); + // Blank strings (only spaces) are returned as-is — nonsensical input, no match expected + assertThat(RegionUtils.getCosmosDBRegionName(" ")).isEqualTo(" "); } @Test(groups = "unit") From dd71bd845196b6bd650ffdbd62eebae9f158a6ab Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 12:35:46 -0400 Subject: [PATCH 19/58] Rewrite E2E tests: add fault injection to PPCB, availability strategy, and exclude region normalization tests --- .../com/azure/cosmos/ExcludeRegionTests.java | 54 +++++++++++++ ...tionWithAvailabilityStrategyTestsBase.java | 49 ++++++++++-- .../PerPartitionCircuitBreakerE2ETests.java | 80 ++++++++++++++----- 3 files changed, 157 insertions(+), 26 deletions(-) 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 1999eda0371f..94ad7b3c9f43 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 @@ -327,6 +327,60 @@ public void excludeRegionTest_uppercaseExcludeRegion_shouldSkipExcludedRegion() validateRegionsContacted(diagnostics, this.preferredRegionList.subList(1, 2)); } + @Test(groups = {"multi-master"}, dataProvider = "faultInjectionArgProvider", timeOut = TIMEOUT) + public void excludeRegionTest_nonCanonicalExcludeRegion_withFaultInjection( + OperationType operationType, + FaultInjectionOperationType faultInjectionOperationType) throws InterruptedException { + + if (this.nonCanonicalPreferredRegionList.size() <= 1) { + throw new SkipException("Test requires multi-master with multi-regions"); + } + + // Client built with non-canonical preferred regions (e.g., "westus3") + // Inject 404/1002 into second region, exclude second region using UPPERCASE name + // Verify request routes only to first region (not the excluded second region) + + TestObject createdItem = TestObject.create(); + this.cosmosAsyncContainerNonCanonical.createItem(createdItem).block(); + + Thread.sleep(2000); + + // Inject fault into second region + FaultInjectionRule serverErrorRule = new FaultInjectionRuleBuilder( + "nonCanonicalExclude-fi-" + operationType + "-" + java.util.UUID.randomUUID()) + .condition( + new FaultInjectionConditionBuilder() + .region(this.preferredRegionList.get(1)) + .operationType(faultInjectionOperationType) + .build()) + .result( + FaultInjectionResultBuilders + .getResultBuilder(FaultInjectionServerErrorType.READ_SESSION_NOT_AVAILABLE) + .build() + ).build(); + + try { + CosmosFaultInjectionHelper.configureFaultInjectionRules( + this.cosmosAsyncContainerNonCanonical, Arrays.asList(serverErrorRule)).block(); + + // Exclude the second region using uppercase name (non-canonical) + String secondRegionUppercase = this.preferredRegionList.get(1).toUpperCase(); + + CosmosDiagnosticsContext diagnostics = this.performDocumentOperation( + cosmosAsyncContainerNonCanonical, + operationType, + createdItem, + Arrays.asList(secondRegionUppercase), + INF_E2E_TIMEOUT); + + // Should route only to the first preferred region — second is both + // excluded (non-canonical uppercase) and faulty (fault injection) + validateRegionsContacted(diagnostics, this.preferredRegionList.subList(0, 1)); + } finally { + serverErrorRule.disable(); + } + } + private List getPreferredRegionList(CosmosAsyncClient client) { assertThat(client).isNotNull(); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java index 68267d960675..6e4f4a2abcde 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 @@ -352,7 +352,8 @@ public void beforeClass() { @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 + // routes correctly with availability strategy — fault injection in first region + // should cause failover to second region via hedging CosmosEndToEndOperationLatencyPolicyConfig e2ePolicy = new CosmosEndToEndOperationLatencyPolicyConfigBuilder(Duration.ofSeconds(10)) @@ -386,6 +387,7 @@ public void readAfterCreation_nonCanonicalPreferredRegions_shouldRouteCorrectly( assertThat(createResponse).isNotNull(); assertThat(createResponse.getStatusCode()).isEqualTo(201); + // Step 1: Read without fault injection — should contact first preferred region CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions(); readOptions.setCosmosEndToEndOperationLatencyPolicyConfig(e2ePolicy); @@ -398,10 +400,47 @@ public void readAfterCreation_nonCanonicalPreferredRegions_shouldRouteCorrectly( 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); + assertThat(diagnosticsContext.getContactedRegionNames()) + .as("Without faults, should contact first preferred region") + .contains(FIRST_REGION_NAME); + + // Step 2: Inject 503 into first region — availability strategy should hedge to second region + String ruleName = "nonCanonical-503-" + UUID.randomUUID(); + FaultInjectionServerErrorResult serviceUnavailableResult = FaultInjectionResultBuilders + .getResultBuilder(FaultInjectionServerErrorType.SERVICE_UNAVAILABLE) + .delay(Duration.ofMillis(5)) + .build(); + + FaultInjectionRule faultRule = new FaultInjectionRuleBuilder(ruleName) + .condition( + new FaultInjectionConditionBuilder() + .connectionType(FaultInjectionConnectionType.DIRECT) + .region(this.writeableRegions.get(0)) + .operationType(FaultInjectionOperationType.READ_ITEM) + .build()) + .result(serviceUnavailableResult) + .build(); + + CosmosFaultInjectionHelper.configureFaultInjectionRules(container, Arrays.asList(faultRule)).block(); + + try { + CosmosItemResponse readWithFaultResponse = container + .readItem(testItem.get("id").asText(), new PartitionKey(testItem.get("mypk").asText()), readOptions, ObjectNode.class) + .block(); + + assertThat(readWithFaultResponse).isNotNull(); + assertThat(readWithFaultResponse.getStatusCode()).isEqualTo(200); + + CosmosDiagnosticsContext faultDiagnostics = readWithFaultResponse.getDiagnostics().getDiagnosticsContext(); + assertThat(faultDiagnostics).isNotNull(); + assertThat(faultDiagnostics.getContactedRegionNames()) + .as("With 503 in first region + eager availability strategy, " + + "should hedge to second region even with non-canonical preferred regions (%s)", + this.nonCanonicalWriteableRegions) + .contains(SECOND_REGION_NAME); + } finally { + faultRule.disable(); + } } finally { safeClose(clientWithNonCanonicalRegions); } 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 b7ed7d6ce72c..5e7979c62d36 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 @@ -5275,12 +5275,23 @@ public void nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly() { throw new SkipException("Test requires multi-region account"); } - // Build space-stripped preferred regions: "West US 3" → "westus3" + // Build non-canonical preferred regions: "West US 3" → "westus3", "East US" → "eastus" List nonCanonicalRegions = new ArrayList<>(); for (String region : this.writeRegions) { nonCanonicalRegions.add(region.toLowerCase(Locale.ROOT).replace(" ", "")); } + String firstRegionCanonicalLower = this.writeRegions.get(0).toLowerCase(Locale.ROOT); + String secondRegionCanonicalLower = this.writeRegions.get(1).toLowerCase(Locale.ROOT); + + System.setProperty( + "COSMOS.PARTITION_LEVEL_CIRCUIT_BREAKER_CONFIG", + "{\"isPartitionLevelCircuitBreakerEnabled\": true, " + + "\"circuitBreakerType\": \"CONSECUTIVE_EXCEPTION_COUNT_BASED\"," + + "\"consecutiveExceptionCountToleratedForReads\": 5," + + "\"consecutiveExceptionCountToleratedForWrites\": 5," + + "}"); + CosmosClientBuilder clientBuilder = getClientBuilder() .multipleWriteRegionsEnabled(true) .preferredRegions(nonCanonicalRegions); @@ -5303,35 +5314,62 @@ public void nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly() { .getDatabase(this.sharedAsyncDatabaseId) .getContainer(this.sharedMultiPartitionAsyncContainerIdWhereIdIsPartitionKey); - // Create an item and read it back — verify routing via diagnostics + // Bootstrap: create a test item TestObject testObject = TestObject.create(); - CosmosItemResponse createResponse = container - .createItem(testObject, new PartitionKey(testObject.getId()), new CosmosItemRequestOptions()) - .block(); + container.createItem(testObject, new PartitionKey(testObject.getId()), new CosmosItemRequestOptions()).block(); - assertThat(createResponse).isNotNull(); - assertThat(createResponse.getStatusCode()).isEqualTo(201); + // Step 1: Inject 503 (ServiceUnavailable) into the first preferred region for READ_ITEM + FaultInjectionCondition faultCondition = new FaultInjectionConditionBuilder() + .connectionType(FaultInjectionConnectionType.DIRECT) + .region(this.writeRegions.get(0)) + .operationType(FaultInjectionOperationType.READ_ITEM) + .build(); - CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions(); - readOptions.setCosmosEndToEndOperationLatencyPolicyConfig(NO_END_TO_END_TIMEOUT); + FaultInjectionServerErrorResult serverError = FaultInjectionResultBuilders + .getResultBuilder(FaultInjectionServerErrorType.SERVICE_UNAVAILABLE) + .build(); - CosmosItemResponse readResponse = container - .readItem(testObject.getId(), new PartitionKey(testObject.getId()), readOptions, TestObject.class) - .block(); + FaultInjectionRule faultRule = new FaultInjectionRuleBuilder("ppcb-non-canonical-region-test-" + UUID.randomUUID()) + .condition(faultCondition) + .result(serverError) + .hitLimit(15) + .build(); + + CosmosFaultInjectionHelper.configureFaultInjectionRules(container, Arrays.asList(faultRule)).block(); - assertThat(readResponse).isNotNull(); - assertThat(readResponse.getStatusCode()).isEqualTo(200); + // Step 2: Issue reads until circuit breaker trips — expect failover to second region + boolean circuitBreakerTripped = false; + + for (int i = 0; i < 20; i++) { + 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(); - CosmosDiagnosticsContext diagnosticsContext = readResponse.getDiagnostics().getDiagnosticsContext(); - assertThat(diagnosticsContext).isNotNull(); - assertThat(diagnosticsContext.getContactedRegionNames()).isNotEmpty(); + assertThat(readResponse).isNotNull(); + assertThat(readResponse.getStatusCode()).isEqualTo(200); - // The contacted region should match the first preferred region (in lowercased canonical form) - String expectedFirstRegion = this.writeRegions.get(0).toLowerCase(Locale.ROOT); - assertThat(diagnosticsContext.getContactedRegionNames()) - .contains(expectedFirstRegion); + CosmosDiagnosticsContext ctx = readResponse.getDiagnostics().getDiagnosticsContext(); + + // Once we see only the second region contacted, the circuit breaker has tripped + if (ctx.getContactedRegionNames().contains(secondRegionCanonicalLower) + && !ctx.getContactedRegionNames().contains(firstRegionCanonicalLower)) { + circuitBreakerTripped = true; + logger.info("Circuit breaker tripped at iteration {}, routing to second region: {}", i, secondRegionCanonicalLower); + break; + } + } + + assertThat(circuitBreakerTripped) + .as("PPCB should have tripped and routed reads to the second preferred region (%s) " + + "even though preferred regions were passed in non-canonical form (%s)", + secondRegionCanonicalLower, nonCanonicalRegions) + .isTrue(); } finally { + System.clearProperty("COSMOS.PARTITION_LEVEL_CIRCUIT_BREAKER_CONFIG"); if (asyncClient != null) { asyncClient.close(); } From 1ac980897020976af9f6aefedb508e4d2b9a89bd Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 12:49:01 -0400 Subject: [PATCH 20/58] =?UTF-8?q?Add=20unknown=20region=20integration=20te?= =?UTF-8?q?sts=20for=20LocationCache=20=E2=80=94=20validates=20routing=20a?= =?UTF-8?q?nd=20exclude=20for=20regions=20not=20in=20static=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../routing/LocationCacheTest.java | 127 ++++++++++++++++++ 1 file changed, 127 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 fee1e09e44ed..4b8b26d005b9 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 @@ -1095,4 +1095,131 @@ public void excludeRegions_requestLevelNoSpacesShouldExclude() { .doesNotContain(EastUSEndpoint); assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); } + + // ======================================================================== + // Unknown region tests — simulates a new Azure region rolled out server-side + // that is NOT yet in the SDK's static REGION_NAME_TO_REGION_ID_MAPPINGS map. + // Customer passes the canonically correct name; routing should still work. + // ======================================================================== + + private static final URI PlutoCentralEndpoint = createUrl("https://plutocentral.documents.azure.com"); + private static final URI MarsSouthEndpoint = createUrl("https://marssouth.documents.azure.com"); + + private static DatabaseAccount createDatabaseAccountWithUnknownRegions() { + return ModelBridgeUtils.createDatabaseAccount( + // Server returns canonical names for new regions not in SDK's static map + ImmutableList.of( + createDatabaseAccountLocation("Pluto Central", PlutoCentralEndpoint.toString()), + createDatabaseAccountLocation("Mars South", MarsSouthEndpoint.toString()), + createDatabaseAccountLocation("East US", EastUSEndpoint.toString())), + ImmutableList.of( + createDatabaseAccountLocation("Pluto Central", PlutoCentralEndpoint.toString()), + createDatabaseAccountLocation("Mars South", MarsSouthEndpoint.toString()), + createDatabaseAccountLocation("East US", EastUSEndpoint.toString())), + true); + } + + private LocationCache createCacheWithUnknownRegions(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(createDatabaseAccountWithUnknownRegions()); + return locationCache; + } + + @Test(groups = "unit") + public void unknownRegion_canonicalNameShouldRouteCorrectly() { + // Customer passes the exact canonical name for a new region not in the static map. + // Server also returns the same canonical name. Routing should work via toLowerCase match. + LocationCache locationCache = createCacheWithUnknownRegions( + Arrays.asList("Pluto Central", "Mars South", "East US")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(PlutoCentralEndpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(MarsSouthEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + } + + @Test(groups = "unit") + public void unknownRegion_lowercaseShouldRouteCorrectly() { + // Customer passes lowercase for a new region not in the static map. + // Should match via CaseInsensitiveMap after toLowerCase. + LocationCache locationCache = createCacheWithUnknownRegions( + Arrays.asList("pluto central", "mars south", "east us")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(PlutoCentralEndpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(MarsSouthEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + } + + @Test(groups = "unit") + public void unknownRegion_uppercaseShouldRouteCorrectly() { + // Customer passes UPPERCASE for a new region not in the static map. + LocationCache locationCache = createCacheWithUnknownRegions( + Arrays.asList("PLUTO CENTRAL", "MARS SOUTH", "EAST US")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(PlutoCentralEndpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(MarsSouthEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + } + + @Test(groups = "unit") + public void unknownRegion_excludeWithCanonicalNameShouldWork() { + // Exclude an unknown region using its canonical name — should still be excluded + LocationCache locationCache = createCacheWithUnknownRegions( + Arrays.asList("Pluto Central", "Mars South", "East US")); + + AtomicReference excludedRef = new AtomicReference<>( + new CosmosExcludedRegions(new HashSet<>(Arrays.asList("Pluto Central")))); + + ConnectionPolicy connectionPolicy = ReflectionUtils.getConnectionPolicy(locationCache); + connectionPolicy.setExcludedRegionsSupplier(excludedRef::get); + + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + + List applicableEndpoints = + locationCache.getApplicableReadRegionRoutingContexts(request); + + // "Pluto Central" should be excluded — first endpoint should be Mars South + assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(MarsSouthEndpoint); + assertThat(applicableEndpoints.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(PlutoCentralEndpoint); + } + + @Test(groups = "unit") + public void unknownRegion_excludeWithDifferentCasingShouldWork() { + // Exclude an unknown region using different casing — equalsIgnoreCase should handle it + LocationCache locationCache = createCacheWithUnknownRegions( + Arrays.asList("Pluto Central", "Mars South", "East US")); + + AtomicReference excludedRef = new AtomicReference<>( + new CosmosExcludedRegions(new HashSet<>(Arrays.asList("PLUTO CENTRAL")))); + + ConnectionPolicy connectionPolicy = ReflectionUtils.getConnectionPolicy(locationCache); + connectionPolicy.setExcludedRegionsSupplier(excludedRef::get); + + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + + List applicableEndpoints = + locationCache.getApplicableReadRegionRoutingContexts(request); + + assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(MarsSouthEndpoint); + assertThat(applicableEndpoints.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(PlutoCentralEndpoint); + } } From d14a4d91d709835e7708c324c602856d8b6d100a Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 12:57:57 -0400 Subject: [PATCH 21/58] Add duplicate ID fail-fast in RegionUtils static block, add PPCB reevaluate regression test with non-canonical user exclude regions --- .../ApplicableRegionEvaluatorTest.java | 85 +++++++++++++++++++ .../implementation/routing/RegionUtils.java | 8 +- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java index d62063f89401..e0e9b407bc99 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java @@ -2380,4 +2380,89 @@ public ResolvedEndpointsContext(URI locationEndpointToRoute, List applicabl } } } + + // ======================================================================== + // Regression test for PPCB reevaluate List.contains() bug fix + // Validates that when user excludes a region in non-canonical form (e.g., "eastus") + // and PPCB internally excludes the same region in server form (e.g., "east us"), + // the reevaluate path does NOT re-add the user-excluded region as a retry target. + // ======================================================================== + + @Test(groups = "unit") + public void reevaluate_nonCanonicalUserExclude_shouldNotReAddPpcbExcludedRegion() { + + // Setup: multi-write 3-region account with non-canonical user exclude for first region + DatabaseAccountManagerInternal databaseAccountManagerInternal = Mockito.mock(DatabaseAccountManagerInternal.class); + + List readableLocations = Arrays.asList( + createDatabaseAccountLocation(EastUsLocation, TestAccountEastUsEndpoint.toString()), + createDatabaseAccountLocation(WestUsLocation, TestAccountWestUsEndpoint.toString()), + createDatabaseAccountLocation(CentralUsLocation, TestAccountCentralUsEndpoint.toString())); + + List writeableLocations = Arrays.asList( + createDatabaseAccountLocation(EastUsLocation, TestAccountEastUsEndpoint.toString()), + createDatabaseAccountLocation(WestUsLocation, TestAccountWestUsEndpoint.toString()), + createDatabaseAccountLocation(CentralUsLocation, TestAccountCentralUsEndpoint.toString())); + + DatabaseAccount databaseAccount = new DatabaseAccount(); + databaseAccount.setWritableLocations(writeableLocations); + databaseAccount.setReadableLocations(readableLocations); + databaseAccount.setEnableMultipleWriteLocations(true); + databaseAccount.setId(AccountId); + + // Use non-canonical preferred regions (space-stripped) + ConnectionPolicy connectionPolicy = new ConnectionPolicy(DirectConnectionConfig.getDefaultConfig()); + connectionPolicy.setEndpointDiscoveryEnabled(true); + connectionPolicy.setMultipleWriteRegionsEnabled(true); + connectionPolicy.setPreferredRegions(Arrays.asList("eastus", "westus", "centralus")); + + Mockito.when(databaseAccountManagerInternal.getDatabaseAccountFromEndpoint(ArgumentMatchers.any())).thenReturn(Flux.just(databaseAccount)); + Mockito.when(databaseAccountManagerInternal.getServiceEndpoint()).thenReturn(TestAccountEndpoint); + Mockito.when(databaseAccountManagerInternal.getConnectionPolicy()).thenReturn(connectionPolicy); + + GlobalEndpointManager globalEndpointManager = new GlobalEndpointManager(databaseAccountManagerInternal, connectionPolicy, new Configs()); + LocationCache locationCache = ReflectionUtils.getLocationCache(globalEndpointManager); + locationCache.onDatabaseAccountRead(databaseAccount); + + try { + // Create request: user excludes "eastus" (non-canonical, no spaces) + // PPCB marks "east us" (server-returned, lowercased with spaces) as unavailable + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + request.requestContext.setExcludeRegions(Arrays.asList("eastus")); + request.requestContext.setUnavailableRegionsForPerPartitionCircuitBreaker(Arrays.asList("east us")); + + request.requestContext.setCrossRegionAvailabilityContext( + new CrossRegionAvailabilityContextForRxDocumentServiceRequest( + null, + new PointOperationContextForCircuitBreaker( + new AtomicBoolean(false), + true, + "coll1", + new SerializationDiagnosticsContext()), + new AvailabilityStrategyContext(false, false), + new AtomicBoolean(false), + new PerPartitionCircuitBreakerInfoHolder(), + new PerPartitionAutomaticFailoverInfoHolder())); + + List applicableEndpoints = + globalEndpointManager.getApplicableReadRegionalRoutingContexts(request); + + // "East US" / "eastus" / "east us" should all be excluded — the reevaluate path + // should NOT re-add "east us" because containsRegionIgnoreCase recognizes + // that "eastus" (user exclude) matches "east us" (PPCB internal exclude). + for (RegionalRoutingContext endpoint : applicableEndpoints) { + Assertions.assertThat(endpoint.getGatewayRegionalEndpoint()) + .as("East US endpoint should not be re-added by reevaluate when user excluded 'eastus' and PPCB excluded 'east us'") + .isNotEqualTo(TestAccountEastUsEndpoint); + } + + // Should route to West US or Central US only + Assertions.assertThat(applicableEndpoints.size()).isGreaterThanOrEqualTo(1); + Assertions.assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()) + .isIn(TestAccountWestUsEndpoint, TestAccountCentralUsEndpoint); + } finally { + globalEndpointManager.close(); + } + } } 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 d486405f74d5..4811968c18e3 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 @@ -181,8 +181,12 @@ public final class RegionUtils { String canonicalName = entry.getKey(); String normalized = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); - normalizedToId.put(normalized, entry.getValue()); - idToNormalized.putIfAbsent(entry.getValue(), normalized); + if (normalizedToId.put(normalized, entry.getValue()) != null) { + throw new IllegalStateException("Duplicate normalized region name '" + normalized + "' in REGION_NAME_TO_REGION_ID_MAPPINGS"); + } + if (idToNormalized.put(entry.getValue(), normalized) != null) { + throw new IllegalStateException("Duplicate region ID " + entry.getValue() + " in REGION_NAME_TO_REGION_ID_MAPPINGS"); + } normalizedToCanonical.putIfAbsent(normalized, canonicalName); } From c3b4052363ad05265d1c401f532b00d1696715d7 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 13:05:38 -0400 Subject: [PATCH 22/58] Normalize region suffix in LocationHelper to lowercase for consistency (DNS is case-insensitive) --- .../implementation/routing/LocationHelper.java | 4 +++- .../implementation/routing/RegionUtils.java | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java index fd5abf33647e..d01aaf6accfc 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java @@ -45,7 +45,9 @@ public static URI getLocationEndpoint(URI serviceEndpoint, String location) { } private static String dataCenterToUriPostfix(String dataCenter) { - return dataCenter.replace(" ", ""); + // Use RegionUtils for consistent normalized representation (lowercase, no spaces). + // DNS is case-insensitive, so casing doesn't affect resolution. + return RegionUtils.getNormalizedRegionName(dataCenter); } } 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 4811968c18e3..7f0736681110 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 @@ -246,6 +246,23 @@ public static String getCosmosDBRegionName(String regionName) { return regionName; } + /** + * Returns the normalized form of a region name: lowercase, no spaces. + *

+ * For known regions, this is the lowercase-no-spaces key (e.g., {@code "westus3"}). + * For unknown regions, this is the input lowercased with spaces stripped. + * Used for constructing regional endpoint URLs (DNS is case-insensitive). + * + * @param regionName the region name to normalize + * @return the lowercase, space-stripped form + */ + public static String getNormalizedRegionName(String regionName) { + if (StringUtils.isEmpty(regionName)) { + return regionName; + } + return regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + } + /** * Normalizes a list of region names to canonical CosmosDB format. * Unknown regions not in the static map are passed through as-is. From dbb51b1d737d1e6a653497f55719e210e1d7765c Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 13:25:23 -0400 Subject: [PATCH 23/58] Centralize normalization: normalize preferred regions once at ConnectionPolicy entry, expose via getNormalizedPreferredRegions() --- .../implementation/ConnectionPolicy.java | 20 ++++++++++++++++--- .../implementation/GlobalEndpointManager.java | 4 ++-- .../implementation/routing/LocationCache.java | 7 ++----- 3 files changed, 21 insertions(+), 10 deletions(-) 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 af9b53bb51df..f4712cf9b6ab 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 @@ -42,6 +42,7 @@ private static ImplementationBridgeHelpers.Http2ConnectionConfigHelper.Http2Conn private boolean endpointDiscoveryEnabled; private boolean multipleWriteRegionsEnabled; private List preferredRegions; + private List normalizedPreferredRegions; private Supplier excludedRegionsSupplier; private boolean readRequestsFallbackEnabled; private ThrottlingRetryOptions throttlingRetryOptions; @@ -489,16 +490,29 @@ public List getPreferredRegions() { public ConnectionPolicy setPreferredRegions(List preferredRegions) { if (preferredRegions == null || preferredRegions.isEmpty()) { this.preferredRegions = preferredRegions; + this.normalizedPreferredRegions = preferredRegions; return this; } - // Store the customer-supplied list as-is. - // Public API (getPreferredRegions) and CosmosDiagnostics should reflect customer input. - // Normalization to canonical CosmosDB form happens internally in LocationCache. + // Store the customer-supplied list as-is for public API and diagnostics. this.preferredRegions = new ArrayList<>(preferredRegions); + + // Pre-normalize for internal consumers (LocationCache, GlobalEndpointManager, etc.) + // so normalization happens once at entry, not scattered across consumers. + this.normalizedPreferredRegions = RegionUtils.normalizeRegionNames(preferredRegions); return this; } + /** + * Gets the pre-normalized preferred regions (canonical CosmosDB form). + * Internal only — used by LocationCache, GlobalEndpointManager, and other routing components. + * + * @return the normalized list of preferred regions. + */ + public List getNormalizedPreferredRegions() { + return this.normalizedPreferredRegions != null ? this.normalizedPreferredRegions : Collections.emptyList(); + } + public ConnectionPolicy setExcludedRegionsSupplier(Supplier excludedRegionsSupplier) { this.excludedRegionsSupplier = excludedRegionsSupplier; return this; diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java index e313b5219713..057c4f42a007 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java @@ -440,8 +440,8 @@ public ConnectionPolicy getConnectionPolicy() { private List getEffectivePreferredRegions() { - if (this.connectionPolicy.getPreferredRegions() != null && !this.connectionPolicy.getPreferredRegions().isEmpty()) { - return this.connectionPolicy.getPreferredRegions(); + if (this.connectionPolicy.getNormalizedPreferredRegions() != null && !this.connectionPolicy.getNormalizedPreferredRegions().isEmpty()) { + return this.connectionPolicy.getNormalizedPreferredRegions(); } // when latestDatabaseAccount is initialized 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 9b59e6a0a252..2e6e80af737b 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 @@ -64,10 +64,7 @@ public LocationCache( URI defaultEndpoint, Configs configs) { - List preferredLocations = new ArrayList<>(connectionPolicy.getPreferredRegions() != null ? - connectionPolicy.getPreferredRegions() : - Collections.emptyList() - ); + List preferredLocations = new ArrayList<>(connectionPolicy.getNormalizedPreferredRegions()); this.defaultRoutingContext = new RegionalRoutingContext(defaultEndpoint); this.locationInfo = new DatabaseAccountLocationsInfo(preferredLocations, this.defaultRoutingContext); @@ -1084,7 +1081,7 @@ static class DatabaseAccountLocationsInfo { public DatabaseAccountLocationsInfo(List preferredLocations, RegionalRoutingContext defaultRoutingContext) { - this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> RegionUtils.getCosmosDBRegionName(loc).toLowerCase(Locale.ROOT)).collect(Collectors.toList())); + this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> loc.toLowerCase(Locale.ROOT)).collect(Collectors.toList())); this.effectivePreferredLocations = new UnmodifiableList<>(Collections.emptyList()); this.availableWriteRegionalRoutingContextsByRegionName = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(new CaseInsensitiveMap<>()); From 1d97a9fb0f021df9a7267204d74261ffa90d6951 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 13:38:51 -0400 Subject: [PATCH 24/58] Fix AssertJ compilation: use Set.contains() instead of assertThat().contains() for Set --- ...ltInjectionWithAvailabilityStrategyTestsBase.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 6e4f4a2abcde..006aef08bc69 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 @@ -402,7 +402,10 @@ public void readAfterCreation_nonCanonicalPreferredRegions_shouldRouteCorrectly( assertThat(diagnosticsContext).isNotNull(); assertThat(diagnosticsContext.getContactedRegionNames()) .as("Without faults, should contact first preferred region") - .contains(FIRST_REGION_NAME); + .isNotNull(); + assertThat(diagnosticsContext.getContactedRegionNames().contains(FIRST_REGION_NAME)) + .as("Without faults, should contact first preferred region") + .isTrue(); // Step 2: Inject 503 into first region — availability strategy should hedge to second region String ruleName = "nonCanonical-503-" + UUID.randomUUID(); @@ -437,7 +440,12 @@ public void readAfterCreation_nonCanonicalPreferredRegions_shouldRouteCorrectly( .as("With 503 in first region + eager availability strategy, " + "should hedge to second region even with non-canonical preferred regions (%s)", this.nonCanonicalWriteableRegions) - .contains(SECOND_REGION_NAME); + .isNotNull(); + assertThat(faultDiagnostics.getContactedRegionNames().contains(SECOND_REGION_NAME)) + .as("With 503 in first region + eager availability strategy, " + + "should hedge to second region even with non-canonical preferred regions (%s)", + this.nonCanonicalWriteableRegions) + .isTrue(); } finally { faultRule.disable(); } From 027e32bbc24ffa0dff22c268dbc89b4af24875ff Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 13:47:46 -0400 Subject: [PATCH 25/58] Rename variables to distinguish canonical vs normalized: getCanonicalRegionName, canonicalPreferredRegions, canonicalRegionNameCache, canonicalizeRegionNames --- .../routing/RegionUtilsNormalizationTest.java | 26 +++++++++---------- .../implementation/ConnectionPolicy.java | 10 +++---- .../implementation/GlobalEndpointManager.java | 4 +-- .../implementation/routing/LocationCache.java | 16 ++++++------ .../implementation/routing/RegionUtils.java | 10 +++---- 5 files changed, 33 insertions(+), 33 deletions(-) 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 604095262915..fc906040a25c 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 @@ -79,35 +79,35 @@ public Object[][] regionNameVariants() { @Test(groups = "unit", dataProvider = "regionNameVariants") public void shouldNormalizeRegionNameVariants(String input, String expectedCanonical) { - String result = RegionUtils.getCosmosDBRegionName(input); + String result = RegionUtils.getCanonicalRegionName(input); assertThat(result).isEqualTo(expectedCanonical); } @Test(groups = "unit") public void shouldNormalizeUnknownRegions() { // Unknown regions should be returned as-is (customer input preserved) - assertThat(RegionUtils.getCosmosDBRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); - assertThat(RegionUtils.getCosmosDBRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); + assertThat(RegionUtils.getCanonicalRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); + assertThat(RegionUtils.getCanonicalRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); } @Test(groups = "unit") public void shouldHandleNullAndEmpty() { - assertThat(RegionUtils.getCosmosDBRegionName(null)).isNull(); - assertThat(RegionUtils.getCosmosDBRegionName("")).isEqualTo(""); + assertThat(RegionUtils.getCanonicalRegionName(null)).isNull(); + assertThat(RegionUtils.getCanonicalRegionName("")).isEqualTo(""); } @Test(groups = "unit") public void shouldHandleBlankString() { // Blank strings (only spaces) are returned as-is — nonsensical input, no match expected - assertThat(RegionUtils.getCosmosDBRegionName(" ")).isEqualTo(" "); + assertThat(RegionUtils.getCanonicalRegionName(" ")).isEqualTo(" "); } @Test(groups = "unit") public void unknownRegionVariantsShouldBeReturnedAsIs() { // Unknown regions: returned as customer-passed string (no transformation) - assertThat(RegionUtils.getCosmosDBRegionName("futureregion99")).isEqualTo("futureregion99"); - assertThat(RegionUtils.getCosmosDBRegionName("Future Region 99")).isEqualTo("Future Region 99"); - assertThat(RegionUtils.getCosmosDBRegionName("FUTURE REGION 99")).isEqualTo("FUTURE REGION 99"); + assertThat(RegionUtils.getCanonicalRegionName("futureregion99")).isEqualTo("futureregion99"); + assertThat(RegionUtils.getCanonicalRegionName("Future Region 99")).isEqualTo("Future Region 99"); + assertThat(RegionUtils.getCanonicalRegionName("FUTURE REGION 99")).isEqualTo("FUTURE REGION 99"); } // ======================================================================== @@ -116,19 +116,19 @@ public void unknownRegionVariantsShouldBeReturnedAsIs() { @Test(groups = "unit") public void normalizeRegionNames_shouldNormalizeList() { - assertThat(RegionUtils.normalizeRegionNames(Arrays.asList("westus3", "east us"))) + assertThat(RegionUtils.canonicalizeRegionNames(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(); + assertThat(RegionUtils.canonicalizeRegionNames(null)).isEmpty(); + assertThat(RegionUtils.canonicalizeRegionNames(Collections.emptyList())).isEmpty(); } @Test(groups = "unit") public void normalizeRegionNames_shouldDropNullElements() { - assertThat(RegionUtils.normalizeRegionNames(Arrays.asList("East US", null, "westus3"))) + assertThat(RegionUtils.canonicalizeRegionNames(Arrays.asList("East US", null, "westus3"))) .containsExactly("East US", "West US 3"); } 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 f4712cf9b6ab..83e891636b1d 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 @@ -42,7 +42,7 @@ private static ImplementationBridgeHelpers.Http2ConnectionConfigHelper.Http2Conn private boolean endpointDiscoveryEnabled; private boolean multipleWriteRegionsEnabled; private List preferredRegions; - private List normalizedPreferredRegions; + private List canonicalPreferredRegions; private Supplier excludedRegionsSupplier; private boolean readRequestsFallbackEnabled; private ThrottlingRetryOptions throttlingRetryOptions; @@ -490,7 +490,7 @@ public List getPreferredRegions() { public ConnectionPolicy setPreferredRegions(List preferredRegions) { if (preferredRegions == null || preferredRegions.isEmpty()) { this.preferredRegions = preferredRegions; - this.normalizedPreferredRegions = preferredRegions; + this.canonicalPreferredRegions = preferredRegions; return this; } @@ -499,7 +499,7 @@ public ConnectionPolicy setPreferredRegions(List preferredRegions) { // Pre-normalize for internal consumers (LocationCache, GlobalEndpointManager, etc.) // so normalization happens once at entry, not scattered across consumers. - this.normalizedPreferredRegions = RegionUtils.normalizeRegionNames(preferredRegions); + this.canonicalPreferredRegions = RegionUtils.canonicalizeRegionNames(preferredRegions); return this; } @@ -509,8 +509,8 @@ public ConnectionPolicy setPreferredRegions(List preferredRegions) { * * @return the normalized list of preferred regions. */ - public List getNormalizedPreferredRegions() { - return this.normalizedPreferredRegions != null ? this.normalizedPreferredRegions : Collections.emptyList(); + public List getCanonicalPreferredRegions() { + return this.canonicalPreferredRegions != null ? this.canonicalPreferredRegions : Collections.emptyList(); } public ConnectionPolicy setExcludedRegionsSupplier(Supplier excludedRegionsSupplier) { diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java index 057c4f42a007..8e2aaa7be218 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java @@ -440,8 +440,8 @@ public ConnectionPolicy getConnectionPolicy() { private List getEffectivePreferredRegions() { - if (this.connectionPolicy.getNormalizedPreferredRegions() != null && !this.connectionPolicy.getNormalizedPreferredRegions().isEmpty()) { - return this.connectionPolicy.getNormalizedPreferredRegions(); + if (this.connectionPolicy.getCanonicalPreferredRegions() != null && !this.connectionPolicy.getCanonicalPreferredRegions().isEmpty()) { + return this.connectionPolicy.getCanonicalPreferredRegions(); } // when latestDatabaseAccount is initialized 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 2e6e80af737b..cdd65d97f06e 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 @@ -51,7 +51,7 @@ public class LocationCache { private final Object lockObject; private final Duration unavailableLocationsExpirationTime; private final ConcurrentHashMap locationUnavailabilityInfoByEndpoint; - private final ConcurrentHashMap normalizedRegionNameCache; + private final ConcurrentHashMap canonicalRegionNameCache; private final ConnectionPolicy connectionPolicy; private DatabaseAccountLocationsInfo locationInfo; @@ -64,7 +64,7 @@ public LocationCache( URI defaultEndpoint, Configs configs) { - List preferredLocations = new ArrayList<>(connectionPolicy.getNormalizedPreferredRegions()); + List preferredLocations = new ArrayList<>(connectionPolicy.getCanonicalPreferredRegions()); this.defaultRoutingContext = new RegionalRoutingContext(defaultEndpoint); this.locationInfo = new DatabaseAccountLocationsInfo(preferredLocations, this.defaultRoutingContext); @@ -74,7 +74,7 @@ public LocationCache( this.lockObject = new Object(); this.locationUnavailabilityInfoByEndpoint = new ConcurrentHashMap<>(); - this.normalizedRegionNameCache = new ConcurrentHashMap<>(); + this.canonicalRegionNameCache = new ConcurrentHashMap<>(); this.lastCacheUpdateTimestamp = Instant.MIN; this.enableMultipleWriteLocations = false; @@ -345,13 +345,13 @@ private UnmodifiableList getApplicableRegionRoutingConte List applicableEndpoints = new ArrayList<>(); // Normalize user-configured exclude regions using cache to avoid per-request allocation. - List normalizedUserExcludeRegions = this.getNormalizedExcludeRegions(userConfiguredExcludeRegions); + List canonicalUserExcludeRegions = this.getCanonicalExcludeRegions(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 (!normalizedUserExcludeRegions.stream().anyMatch(regionName.v::equalsIgnoreCase)) { + if (!canonicalUserExcludeRegions.stream().anyMatch(regionName.v::equalsIgnoreCase)) { applicableEndpoints.add(endpoint); } } @@ -394,7 +394,7 @@ private UnmodifiableList getApplicableRegionRoutingConte new UnmodifiableList<>(applicableEndpoints), regionNameByRegionalRoutingContext, regionalRoutingContextByRegionName, - normalizedUserExcludeRegions, + canonicalUserExcludeRegions, endpointsRemovedByInternalExcludeRegions, internalExcludeRegions, regionalRoutingContexts, @@ -520,14 +520,14 @@ private boolean isExcludeRegionsConfigured(List excludedRegionsOnRequest return isExcludedRegionsConfiguredOnRequest || isExcludedRegionsConfiguredOnClient; } - private List getNormalizedExcludeRegions(List excludeRegions) { + private List getCanonicalExcludeRegions(List excludeRegions) { if (excludeRegions == null || excludeRegions.isEmpty()) { return Collections.emptyList(); } List normalized = new ArrayList<>(excludeRegions.size()); for (String region : excludeRegions) { if (region != null) { - normalized.add(this.normalizedRegionNameCache.computeIfAbsent(region, RegionUtils::getCosmosDBRegionName)); + normalized.add(this.canonicalRegionNameCache.computeIfAbsent(region, RegionUtils::getCosmosDBRegionName)); } } return normalized; 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 7f0736681110..bfdc89daf321 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 @@ -229,7 +229,7 @@ public static int getRegionId(String regionName) { * @return the canonical CosmosDB region name, or the customer-passed * string as-is if unrecognized */ - public static String getCosmosDBRegionName(String regionName) { + public static String getCanonicalRegionName(String regionName) { if (StringUtils.isEmpty(regionName)) { return regionName; } @@ -270,14 +270,14 @@ public static String getNormalizedRegionName(String regionName) { * @param regionNames the list of region names to normalize * @return a new list with each region normalized */ - public static List normalizeRegionNames(List regionNames) { + public static List canonicalizeRegionNames(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)); + normalized.add(getCanonicalRegionName(region)); } } return normalized; @@ -295,9 +295,9 @@ public static boolean containsRegionIgnoreCase(List regions, String targ if (regions == null || regions.isEmpty()) { return false; } - String normalizedTarget = getCosmosDBRegionName(target); + String normalizedTarget = getCanonicalRegionName(target); for (String region : regions) { - if (region != null && getCosmosDBRegionName(region).equalsIgnoreCase(normalizedTarget)) { + if (region != null && getCanonicalRegionName(region).equalsIgnoreCase(normalizedTarget)) { return true; } } From 4ef31d1cdda0f0d1515ba098cca954d7ff3e0d1e Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 13:52:06 -0400 Subject: [PATCH 26/58] Make RegionUtils explicit about normalized vs canonical in all javadoc, variable names, and inline comments --- .../implementation/routing/RegionUtils.java | 124 ++++++++++++------ 1 file changed, 82 insertions(+), 42 deletions(-) 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 bfdc89daf321..51bf75488fdc 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 @@ -164,30 +164,41 @@ public final class RegionUtils { } }); + // ======================================================================== + // Derived maps — built from REGION_NAME_TO_REGION_ID_MAPPINGS. + // + // Naming convention: + // "normalized" = lowercase, no spaces (e.g., "westus3") + // "canonical" = official display form (e.g., "West US 3") + // ======================================================================== + + /** Maps region ID → normalized name (e.g., 77 → "westus3"). */ public static final Map REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS; + /** Maps normalized name → region ID (e.g., "westus3" → 77). */ public static final Map NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS; - // Static map: lowercase-no-spaces key → canonical display name + /** Maps normalized name → canonical display name (e.g., "westus3" → "West US 3"). */ private static final Map NORMALIZED_TO_CANONICAL; static { - // Derive all maps programmatically from REGION_NAME_TO_REGION_ID_MAPPINGS + // Derive all maps programmatically from REGION_NAME_TO_REGION_ID_MAPPINGS. + // Keys in the source map are canonical names (e.g., "West US 3"). Map idToNormalized = new HashMap<>(); Map normalizedToId = new HashMap<>(); Map normalizedToCanonical = new HashMap<>(); for (Map.Entry entry : REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { - String canonicalName = entry.getKey(); - String normalized = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); + String canonicalName = entry.getKey(); // e.g., "West US 3" + String normalizedName = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); // e.g., "westus3" - if (normalizedToId.put(normalized, entry.getValue()) != null) { - throw new IllegalStateException("Duplicate normalized region name '" + normalized + "' in REGION_NAME_TO_REGION_ID_MAPPINGS"); + if (normalizedToId.put(normalizedName, entry.getValue()) != null) { + throw new IllegalStateException("Duplicate normalized region name '" + normalizedName + "' in REGION_NAME_TO_REGION_ID_MAPPINGS"); } - if (idToNormalized.put(entry.getValue(), normalized) != null) { + if (idToNormalized.put(entry.getValue(), normalizedName) != null) { throw new IllegalStateException("Duplicate region ID " + entry.getValue() + " in REGION_NAME_TO_REGION_ID_MAPPINGS"); } - normalizedToCanonical.putIfAbsent(normalized, canonicalName); + normalizedToCanonical.putIfAbsent(normalizedName, canonicalName); } NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(normalizedToId); @@ -198,47 +209,64 @@ public final class RegionUtils { private RegionUtils() { } + /** + * Returns the normalized name for a region ID. + * + * @param regionId the numeric region ID + * @return the normalized name (e.g., "westus3"), or empty string if unknown + */ public static String getRegionName(int regionId) { return REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.getOrDefault(regionId, StringUtils.EMPTY); } + /** + * Returns the region ID for a region name (any format accepted). + *

+ * Fast path: tries the raw key first (zero allocations for already-normalized input). + * Slow path: normalizes (lowercase + strip spaces) and retries. + * + * @param regionName the region name in any format (canonical, normalized, or raw) + * @return the region ID, or -1 if not found + */ public static int getRegionId(String regionName) { if (StringUtils.isEmpty(regionName)) { return -1; } - // Fast path: try raw key first (avoids string allocation for already-normalized input) + // Fast path: input is already in normalized form (e.g., "westus3") int id = NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(regionName, -1); if (id != -1) { return id; } - // Slow path: normalize and retry - String normalized = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); - return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(normalized, -1); + // Slow path: normalize input to lowercase-no-spaces and retry + String normalizedName = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(normalizedName, -1); } /** - * Normalizes a region name to the canonical CosmosDB format. + * Returns the canonical CosmosDB region name for any input variant. *

- * 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 customer-passed string as-is - * for forward compatibility — downstream consumers in LocationCache - * apply toLowerCase() or equalsIgnoreCase() as needed. + * Converts input to normalized form (lowercase, no spaces), then looks up + * the canonical display name in the static map. + *

    + *
  • Known region: returns canonical form (e.g., "westus3" → "West US 3")
  • + *
  • Unknown region: returns customer-passed string as-is
  • + *
* - * @param regionName the region name to normalize (any casing/spacing variant) - * @return the canonical CosmosDB region name, or the customer-passed - * string as-is if unrecognized + * @param regionName the region name in any format (e.g., "westus3", "WEST US 3", "West US 3") + * @return the canonical region name (e.g., "West US 3"), or the input as-is if unrecognized */ public static String getCanonicalRegionName(String regionName) { if (StringUtils.isEmpty(regionName)) { return regionName; } - String normalized = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + // Normalize to lowercase-no-spaces for map lookup + String normalizedName = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); - String canonical = NORMALIZED_TO_CANONICAL.get(normalized); - if (canonical != null) { - return canonical; + // Look up canonical display name + String canonicalName = NORMALIZED_TO_CANONICAL.get(normalizedName); + if (canonicalName != null) { + return canonicalName; } // Unknown region — return customer-passed string as-is. @@ -249,12 +277,17 @@ public static String getCanonicalRegionName(String regionName) { /** * Returns the normalized form of a region name: lowercase, no spaces. *

- * For known regions, this is the lowercase-no-spaces key (e.g., {@code "westus3"}). - * For unknown regions, this is the input lowercased with spaces stripped. - * Used for constructing regional endpoint URLs (DNS is case-insensitive). + * Examples: + *

    + *
  • "West US 3" → "westus3"
  • + *
  • "EAST US" → "eastus"
  • + *
  • "Future Region" → "futureregion"
  • + *
+ * Used by {@code LocationHelper} for constructing regional endpoint URLs. + * DNS is case-insensitive, so casing doesn't affect resolution. * - * @param regionName the region name to normalize - * @return the lowercase, space-stripped form + * @param regionName the region name in any format + * @return the normalized form (lowercase, no spaces) */ public static String getNormalizedRegionName(String regionName) { if (StringUtils.isEmpty(regionName)) { @@ -264,40 +297,47 @@ public static String getNormalizedRegionName(String regionName) { } /** - * Normalizes a list of region names to canonical CosmosDB format. - * Unknown regions not in the static map are passed through as-is. + * Converts a list of region names to their canonical CosmosDB form. + *

+ * Known regions are mapped to canonical names (e.g., "westus3" → "West US 3"). + * Unknown regions are passed through as-is. Null elements are dropped. * - * @param regionNames the list of region names to normalize - * @return a new list with each region normalized + * @param regionNames the list of region names in any format + * @return a new list with each region in canonical form */ public static List canonicalizeRegionNames(List regionNames) { if (regionNames == null || regionNames.isEmpty()) { return Collections.emptyList(); } - List normalized = new ArrayList<>(regionNames.size()); + List canonicalized = new ArrayList<>(regionNames.size()); for (String region : regionNames) { if (region != null) { - normalized.add(getCanonicalRegionName(region)); + canonicalized.add(getCanonicalRegionName(region)); } } - return normalized; + return canonicalized; } /** * Checks whether a list of region names contains the target region, * using canonical normalization + case-insensitive comparison. + *

+ * Both the list elements and the target are first canonicalized via + * {@link #getCanonicalRegionName(String)}, then compared with + * {@code equalsIgnoreCase}. This handles all format variants: + * "westus3", "West US 3", "WEST US 3" all match each other. * - * @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 + * @param regions the list of region names to search (any format) + * @param target the target region name to find (any format) + * @return true if any region in the list matches the target after canonicalization */ public static boolean containsRegionIgnoreCase(List regions, String target) { if (regions == null || regions.isEmpty()) { return false; } - String normalizedTarget = getCanonicalRegionName(target); + String canonicalTarget = getCanonicalRegionName(target); for (String region : regions) { - if (region != null && getCanonicalRegionName(region).equalsIgnoreCase(normalizedTarget)) { + if (region != null && getCanonicalRegionName(region).equalsIgnoreCase(canonicalTarget)) { return true; } } From 6be7bfc82eaec65e232704034d1e80de7c50bfdf Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 13:55:34 -0400 Subject: [PATCH 27/58] Fix stale getCosmosDBRegionName reference, clarify canonical vs normalized in all comments across ConnectionPolicy, LocationCache, LocationHelper --- .../cosmos/implementation/ConnectionPolicy.java | 12 ++++++++---- .../implementation/routing/LocationCache.java | 13 +++++++++---- .../implementation/routing/LocationHelper.java | 2 +- 3 files changed, 18 insertions(+), 9 deletions(-) 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 83e891636b1d..37750a116fdd 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 @@ -497,17 +497,21 @@ public ConnectionPolicy setPreferredRegions(List preferredRegions) { // Store the customer-supplied list as-is for public API and diagnostics. this.preferredRegions = new ArrayList<>(preferredRegions); - // Pre-normalize for internal consumers (LocationCache, GlobalEndpointManager, etc.) - // so normalization happens once at entry, not scattered across consumers. + // Pre-canonicalize for internal consumers (LocationCache, GlobalEndpointManager, etc.) + // so canonicalization happens once at entry, not scattered across consumers. + // Canonical = official display form, e.g., "West US 3" (not normalized "westus3"). this.canonicalPreferredRegions = RegionUtils.canonicalizeRegionNames(preferredRegions); return this; } /** - * Gets the pre-normalized preferred regions (canonical CosmosDB form). + * Gets the pre-canonicalized preferred regions (official CosmosDB display form). * Internal only — used by LocationCache, GlobalEndpointManager, and other routing components. + *

+ * Canonical form examples: "West US 3", "East US", "North Europe". + * For unknown regions, the customer-passed string is returned as-is. * - * @return the normalized list of preferred regions. + * @return the canonical list of preferred regions. */ public List getCanonicalPreferredRegions() { return this.canonicalPreferredRegions != null ? this.canonicalPreferredRegions : Collections.emptyList(); 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 cdd65d97f06e..572b33d7221e 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 @@ -344,7 +344,8 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); - // Normalize user-configured exclude regions using cache to avoid per-request allocation. + // Canonicalize user-configured exclude regions using cache to avoid per-request string allocation. + // Canonical = official display form (e.g., "westus3" → "West US 3"). List canonicalUserExcludeRegions = this.getCanonicalExcludeRegions(userConfiguredExcludeRegions); // exclude those regions which are user excluded first @@ -524,13 +525,15 @@ private List getCanonicalExcludeRegions(List excludeRegions) { if (excludeRegions == null || excludeRegions.isEmpty()) { return Collections.emptyList(); } - List normalized = new ArrayList<>(excludeRegions.size()); + // Convert each exclude region to canonical form via cache. + // Cache key = raw customer string, value = canonical name from RegionUtils. + List canonicalized = new ArrayList<>(excludeRegions.size()); for (String region : excludeRegions) { if (region != null) { - normalized.add(this.canonicalRegionNameCache.computeIfAbsent(region, RegionUtils::getCosmosDBRegionName)); + canonicalized.add(this.canonicalRegionNameCache.computeIfAbsent(region, RegionUtils::getCanonicalRegionName)); } } - return normalized; + return canonicalized; } public RegionalRoutingContext resolveFaultInjectionEndpoint(String region, boolean writeOnly) { @@ -1081,6 +1084,8 @@ static class DatabaseAccountLocationsInfo { public DatabaseAccountLocationsInfo(List preferredLocations, RegionalRoutingContext defaultRoutingContext) { + // Input preferredLocations are already in canonical form (from ConnectionPolicy.getCanonicalPreferredRegions()). + // Lowercase for CaseInsensitiveMap key matching against server-returned names. this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> loc.toLowerCase(Locale.ROOT)).collect(Collectors.toList())); this.effectivePreferredLocations = new UnmodifiableList<>(Collections.emptyList()); this.availableWriteRegionalRoutingContextsByRegionName diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java index d01aaf6accfc..8889e6d2eebb 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java @@ -45,7 +45,7 @@ public static URI getLocationEndpoint(URI serviceEndpoint, String location) { } private static String dataCenterToUriPostfix(String dataCenter) { - // Use RegionUtils for consistent normalized representation (lowercase, no spaces). + // Convert to normalized form (lowercase, no spaces) for use as URL suffix. // DNS is case-insensitive, so casing doesn't affect resolution. return RegionUtils.getNormalizedRegionName(dataCenter); } From 6f72c2c6ac9508fb64e7177df255d880adc579f9 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 13:57:42 -0400 Subject: [PATCH 28/58] Make CHANGELOG entry more concise --- sdk/cosmos/azure-cosmos/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/cosmos/azure-cosmos/CHANGELOG.md b/sdk/cosmos/azure-cosmos/CHANGELOG.md index a32ea098f168..f5315d166a25 100644 --- a/sdk/cosmos/azure-cosmos/CHANGELOG.md +++ b/sdk/cosmos/azure-cosmos/CHANGELOG.md @@ -7,7 +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) +* Fixed region name normalization for preferred and excluded regions — non-canonical inputs (e.g., `"westus3"`, `"WEST US 3"`) are now mapped to the canonical form. Also fixed a case-sensitive exclude-region check in PPCB reevaluate logic. - 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) From ae5081227d7ca17a687cb8abdf70dbea7ebe06a1 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 14:00:53 -0400 Subject: [PATCH 29/58] Rename REGION_NAME_TO_REGION_ID_MAPPINGS to CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS, fix all references --- .../cosmos/implementation/RegionUtilsTests.java | 2 +- .../cosmos/implementation/routing/RegionUtils.java | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java index 0cc52bc0b41c..5fa0c5b61a6d 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java @@ -21,7 +21,7 @@ public class RegionUtilsTests { @Test(groups = {"unit"}) public void regionIdToRegionNameConsistency() { - for (Map.Entry sourceEntry : RegionUtils.REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { + for (Map.Entry sourceEntry : RegionUtils.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { String normalizedRegionNameFromSource = sourceEntry.getKey().toLowerCase(Locale.ROOT).replace(" ", "").trim(); Integer regionIdFromSource = sourceEntry.getValue(); 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 51bf75488fdc..d35befead646 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 final 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 = Collections.unmodifiableMap(new HashMap() { + public static final Map CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(new HashMap() { { put("East US", 1); put("East US 2", 2); @@ -165,7 +165,7 @@ public final class RegionUtils { }); // ======================================================================== - // Derived maps — built from REGION_NAME_TO_REGION_ID_MAPPINGS. + // Derived maps — built from CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS. // // Naming convention: // "normalized" = lowercase, no spaces (e.g., "westus3") @@ -182,21 +182,21 @@ public final class RegionUtils { private static final Map NORMALIZED_TO_CANONICAL; static { - // Derive all maps programmatically from REGION_NAME_TO_REGION_ID_MAPPINGS. + // Derive all maps programmatically from CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS. // Keys in the source map are canonical names (e.g., "West US 3"). Map idToNormalized = new HashMap<>(); Map normalizedToId = new HashMap<>(); Map normalizedToCanonical = new HashMap<>(); - for (Map.Entry entry : REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { + for (Map.Entry entry : CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { String canonicalName = entry.getKey(); // e.g., "West US 3" String normalizedName = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); // e.g., "westus3" if (normalizedToId.put(normalizedName, entry.getValue()) != null) { - throw new IllegalStateException("Duplicate normalized region name '" + normalizedName + "' in REGION_NAME_TO_REGION_ID_MAPPINGS"); + throw new IllegalStateException("Duplicate normalized region name '" + normalizedName + "' in CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS"); } if (idToNormalized.put(entry.getValue(), normalizedName) != null) { - throw new IllegalStateException("Duplicate region ID " + entry.getValue() + " in REGION_NAME_TO_REGION_ID_MAPPINGS"); + throw new IllegalStateException("Duplicate region ID " + entry.getValue() + " in CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS"); } normalizedToCanonical.putIfAbsent(normalizedName, canonicalName); } From 5b43ee520a4cc8f9fe281743d690d8449a5f1cfa Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 15:01:21 -0400 Subject: [PATCH 30/58] Simplify: scope all canonicalization to LocationCache, revert ConnectionPolicy to raw-only, inline exclude region canonicalization (zero list allocation) --- .../implementation/ConnectionPolicy.java | 26 ++---------- .../implementation/GlobalEndpointManager.java | 4 +- .../implementation/routing/LocationCache.java | 41 ++++++++----------- 3 files changed, 22 insertions(+), 49 deletions(-) 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 37750a116fdd..c2db3034568b 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,6 @@ import com.azure.cosmos.Http2ConnectionConfig; import com.azure.cosmos.ThrottlingRetryOptions; -import com.azure.cosmos.implementation.routing.RegionUtils; - import java.time.Duration; import java.util.ArrayList; import java.util.Collections; @@ -42,7 +40,6 @@ private static ImplementationBridgeHelpers.Http2ConnectionConfigHelper.Http2Conn private boolean endpointDiscoveryEnabled; private boolean multipleWriteRegionsEnabled; private List preferredRegions; - private List canonicalPreferredRegions; private Supplier excludedRegionsSupplier; private boolean readRequestsFallbackEnabled; private ThrottlingRetryOptions throttlingRetryOptions; @@ -490,33 +487,16 @@ public List getPreferredRegions() { public ConnectionPolicy setPreferredRegions(List preferredRegions) { if (preferredRegions == null || preferredRegions.isEmpty()) { this.preferredRegions = preferredRegions; - this.canonicalPreferredRegions = preferredRegions; return this; } - // Store the customer-supplied list as-is for public API and diagnostics. + // Store the customer-supplied list as-is. + // Public API (getPreferredRegions) and CosmosDiagnostics reflect customer input. + // Canonicalization to official CosmosDB form happens internally in LocationCache. this.preferredRegions = new ArrayList<>(preferredRegions); - - // Pre-canonicalize for internal consumers (LocationCache, GlobalEndpointManager, etc.) - // so canonicalization happens once at entry, not scattered across consumers. - // Canonical = official display form, e.g., "West US 3" (not normalized "westus3"). - this.canonicalPreferredRegions = RegionUtils.canonicalizeRegionNames(preferredRegions); return this; } - /** - * Gets the pre-canonicalized preferred regions (official CosmosDB display form). - * Internal only — used by LocationCache, GlobalEndpointManager, and other routing components. - *

- * Canonical form examples: "West US 3", "East US", "North Europe". - * For unknown regions, the customer-passed string is returned as-is. - * - * @return the canonical list of preferred regions. - */ - public List getCanonicalPreferredRegions() { - return this.canonicalPreferredRegions != null ? this.canonicalPreferredRegions : Collections.emptyList(); - } - public ConnectionPolicy setExcludedRegionsSupplier(Supplier excludedRegionsSupplier) { this.excludedRegionsSupplier = excludedRegionsSupplier; return this; diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java index 8e2aaa7be218..e313b5219713 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/GlobalEndpointManager.java @@ -440,8 +440,8 @@ public ConnectionPolicy getConnectionPolicy() { private List getEffectivePreferredRegions() { - if (this.connectionPolicy.getCanonicalPreferredRegions() != null && !this.connectionPolicy.getCanonicalPreferredRegions().isEmpty()) { - return this.connectionPolicy.getCanonicalPreferredRegions(); + if (this.connectionPolicy.getPreferredRegions() != null && !this.connectionPolicy.getPreferredRegions().isEmpty()) { + return this.connectionPolicy.getPreferredRegions(); } // when latestDatabaseAccount is initialized 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 572b33d7221e..2193f3896594 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 @@ -64,7 +64,10 @@ public LocationCache( URI defaultEndpoint, Configs configs) { - List preferredLocations = new ArrayList<>(connectionPolicy.getCanonicalPreferredRegions()); + List preferredLocations = new ArrayList<>(connectionPolicy.getPreferredRegions() != null ? + connectionPolicy.getPreferredRegions() : + Collections.emptyList() + ); this.defaultRoutingContext = new RegionalRoutingContext(defaultEndpoint); this.locationInfo = new DatabaseAccountLocationsInfo(preferredLocations, this.defaultRoutingContext); @@ -344,15 +347,17 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); - // Canonicalize user-configured exclude regions using cache to avoid per-request string allocation. - // Canonical = official display form (e.g., "westus3" → "West US 3"). - List canonicalUserExcludeRegions = this.getCanonicalExcludeRegions(userConfiguredExcludeRegions); + // Canonicalize user-configured exclude regions inline using cache to avoid per-request list allocation. + // canonicalRegionNameCache: raw customer string → canonical form (e.g., "westus3" → "West US 3"). // 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 (!canonicalUserExcludeRegions.stream().anyMatch(regionName.v::equalsIgnoreCase)) { + if (!userConfiguredExcludeRegions.stream() + .anyMatch(excludeRegion -> this.canonicalRegionNameCache + .computeIfAbsent(excludeRegion, RegionUtils::getCanonicalRegionName) + .equalsIgnoreCase(regionName.v))) { applicableEndpoints.add(endpoint); } } @@ -395,7 +400,7 @@ private UnmodifiableList getApplicableRegionRoutingConte new UnmodifiableList<>(applicableEndpoints), regionNameByRegionalRoutingContext, regionalRoutingContextByRegionName, - canonicalUserExcludeRegions, + userConfiguredExcludeRegions, endpointsRemovedByInternalExcludeRegions, internalExcludeRegions, regionalRoutingContexts, @@ -521,21 +526,6 @@ private boolean isExcludeRegionsConfigured(List excludedRegionsOnRequest return isExcludedRegionsConfiguredOnRequest || isExcludedRegionsConfiguredOnClient; } - private List getCanonicalExcludeRegions(List excludeRegions) { - if (excludeRegions == null || excludeRegions.isEmpty()) { - return Collections.emptyList(); - } - // Convert each exclude region to canonical form via cache. - // Cache key = raw customer string, value = canonical name from RegionUtils. - List canonicalized = new ArrayList<>(excludeRegions.size()); - for (String region : excludeRegions) { - if (region != null) { - canonicalized.add(this.canonicalRegionNameCache.computeIfAbsent(region, RegionUtils::getCanonicalRegionName)); - } - } - return canonicalized; - } - public RegionalRoutingContext resolveFaultInjectionEndpoint(String region, boolean writeOnly) { Utils.ValueHolder endpointValueHolder = new Utils.ValueHolder<>(); if (writeOnly) { @@ -1084,9 +1074,12 @@ static class DatabaseAccountLocationsInfo { public DatabaseAccountLocationsInfo(List preferredLocations, RegionalRoutingContext defaultRoutingContext) { - // Input preferredLocations are already in canonical form (from ConnectionPolicy.getCanonicalPreferredRegions()). - // Lowercase for CaseInsensitiveMap key matching against server-returned names. - this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> loc.toLowerCase(Locale.ROOT)).collect(Collectors.toList())); + // Canonicalize each preferred region, then lowercase for CaseInsensitiveMap key matching. + // Canonical = official display form (e.g., "westus3" → "West US 3" → "west us 3"). + // Unknown regions are passed through as-is, then lowercased. + this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream() + .map(loc -> RegionUtils.getCanonicalRegionName(loc).toLowerCase(Locale.ROOT)) + .collect(Collectors.toList())); this.effectivePreferredLocations = new UnmodifiableList<>(Collections.emptyList()); this.availableWriteRegionalRoutingContextsByRegionName = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(new CaseInsensitiveMap<>()); From 8649bca51271d6462dc39cb4ab0c1b6272d900ab Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 15:28:53 -0400 Subject: [PATCH 31/58] Remove System.clearProperty for PPCB config in test finally block --- .../com/azure/cosmos/PerPartitionCircuitBreakerE2ETests.java | 1 - 1 file changed, 1 deletion(-) 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 5e7979c62d36..cd48c3487c5b 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 @@ -5369,7 +5369,6 @@ public void nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly() { .isTrue(); } finally { - System.clearProperty("COSMOS.PARTITION_LEVEL_CIRCUIT_BREAKER_CONFIG"); if (asyncClient != null) { asyncClient.close(); } From 47562cd7fba2844530ed0bb61084965cf49e486b Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 15:30:00 -0400 Subject: [PATCH 32/58] =?UTF-8?q?Revert=20ConnectionPolicy.java=20to=20mai?= =?UTF-8?q?n=20=E2=80=94=20no=20changes=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../azure/cosmos/implementation/ConnectionPolicy.java | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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 c2db3034568b..b93171e3bdcd 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 @@ -13,7 +13,6 @@ import com.azure.cosmos.ThrottlingRetryOptions; import java.time.Duration; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Objects; @@ -485,15 +484,7 @@ public List getPreferredRegions() { * @return the ConnectionPolicy. */ public ConnectionPolicy setPreferredRegions(List preferredRegions) { - if (preferredRegions == null || preferredRegions.isEmpty()) { - this.preferredRegions = preferredRegions; - return this; - } - - // Store the customer-supplied list as-is. - // Public API (getPreferredRegions) and CosmosDiagnostics reflect customer input. - // Canonicalization to official CosmosDB form happens internally in LocationCache. - this.preferredRegions = new ArrayList<>(preferredRegions); + this.preferredRegions = preferredRegions; return this; } From 5b960a6a2cc16dbbacda41da3fdad128293ff90c Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 15:39:31 -0400 Subject: [PATCH 33/58] Rename getRegionName(int) to getNormalizedRegionName(int) for consistency --- .../com/azure/cosmos/CosmosTracerTest.java | 2 +- ...tionWithAvailabilityStrategyTestsBase.java | 4 +- ...titionEndpointManagerForPPCBUnitTests.java | 12 ++--- ...ScopedSessionContainerConcurrencyTest.java | 6 +-- .../RegionScopedSessionContainerTest.java | 54 +++++++++---------- .../SessionNotAvailableRetryTest.java | 12 ++--- .../PartitionScopedRegionLevelProgress.java | 2 +- .../implementation/routing/RegionUtils.java | 2 +- 8 files changed, 47 insertions(+), 47 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosTracerTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosTracerTest.java index f8e2979f544d..b4a7c7bac61a 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosTracerTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosTracerTest.java @@ -1964,7 +1964,7 @@ private void assertStoreResponseStatistics( attributes.put("rntbd.url", storeResultDiagnostics.getStorePhysicalAddressAsString()); attributes.put("rntbd.resource_type", responseStatistics.getRequestResourceType().toString()); attributes.put("rntbd.operation_type", responseStatistics.getRequestOperationType().toString()); - attributes.put("rntbd.region", responseStatistics.getRegionName()); + attributes.put("rntbd.region", responseStatistics.getNormalizedRegionName()); if (storeResultDiagnostics.getLsn() > 0) { attributes.put("rntbd.lsn", Long.toString(storeResultDiagnostics.getLsn())); 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 006aef08bc69..ebe227072026 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 @@ -1804,7 +1804,7 @@ public Object[][] testConfigs_writeAfterCreation() { firstRequestStart = currentStoreResponse.getRequestStartTimeUTC(); } - if (currentStoreResponse.getRegionName().equals(SECOND_REGION_NAME) && + if (currentStoreResponse.getNormalizedRegionName().equals(SECOND_REGION_NAME) && currentStoreResponse.getRequestStartTimeUTC().isBefore(firstRequestStartInSecondRegion)) { firstRequestStartInSecondRegion = currentStoreResponse.getRequestStartTimeUTC(); @@ -1867,7 +1867,7 @@ public Object[][] testConfigs_writeAfterCreation() { firstRequestStart = currentStoreResponse.getRequestStartTimeUTC(); } - if (currentStoreResponse.getRegionName().equals(SECOND_REGION_NAME) && + if (currentStoreResponse.getNormalizedRegionName().equals(SECOND_REGION_NAME) && currentStoreResponse.getRequestStartTimeUTC().isBefore(firstRequestStartInSecondRegion)) { firstRequestStartInSecondRegion = currentStoreResponse.getRequestStartTimeUTC(); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/GlobalPartitionEndpointManagerForPPCBUnitTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/GlobalPartitionEndpointManagerForPPCBUnitTests.java index cc7d589836eb..fdc63e684f5d 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/GlobalPartitionEndpointManagerForPPCBUnitTests.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/GlobalPartitionEndpointManagerForPPCBUnitTests.java @@ -62,22 +62,22 @@ public void beforeClass() { this.globalEndpointManagerMock = Mockito.mock(GlobalEndpointManager.class); Mockito - .when(this.globalEndpointManagerMock.getRegionName(LocationEastUsEndpointToLocationPair.getKey(), OperationType.Read)) + .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationEastUsEndpointToLocationPair.getKey(), OperationType.Read)) .thenReturn(LocationEastUsEndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getRegionName(LocationEastUsEndpointToLocationPair.getKey(), OperationType.Create)) + .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationEastUsEndpointToLocationPair.getKey(), OperationType.Create)) .thenReturn(LocationEastUsEndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getRegionName(LocationCentralUsEndpointToLocationPair.getKey(), OperationType.Read)) + .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationCentralUsEndpointToLocationPair.getKey(), OperationType.Read)) .thenReturn(LocationCentralUsEndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getRegionName(LocationCentralUsEndpointToLocationPair.getKey(), OperationType.Create)) + .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationCentralUsEndpointToLocationPair.getKey(), OperationType.Create)) .thenReturn(LocationCentralUsEndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getRegionName(LocationEastUs2EndpointToLocationPair.getKey(), OperationType.Read)) + .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationEastUs2EndpointToLocationPair.getKey(), OperationType.Read)) .thenReturn(LocationEastUs2EndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getRegionName(LocationEastUs2EndpointToLocationPair.getKey(), OperationType.Create)) + .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationEastUs2EndpointToLocationPair.getKey(), OperationType.Create)) .thenReturn(LocationEastUs2EndpointToLocationPair.getRight()); } diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerConcurrencyTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerConcurrencyTest.java index 2512f9bd3508..87ec54c7f97a 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerConcurrencyTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerConcurrencyTest.java @@ -257,11 +257,11 @@ public void concurrentSetAndResolveTokens(String profileName, new UnmodifiableList<>(endpointBuilder.build()); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(EAST_US), Mockito.any())) + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(EAST_US), Mockito.any())) .thenReturn(REGION_EAST_US); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(EAST_US2), Mockito.any())) + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(EAST_US2), Mockito.any())) .thenReturn(REGION_EAST_US2); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(CENTRAL_US), Mockito.any())) + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(CENTRAL_US), Mockito.any())) .thenReturn(REGION_CENTRAL_US); Mockito.when(globalEndpointManagerMock.canUseMultipleWriteLocations(Mockito.any())).thenReturn(true); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerTest.java index d1fb33e71dae..053444906e5e 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerTest.java @@ -384,7 +384,7 @@ public void sessionContainer() throws Exception { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(regionContacted); for (int i = 0; i < numCollections; i++) { String collectionResourceId = @@ -448,7 +448,7 @@ public void setSessionToken_NoSessionTokenForPartitionKeyRangeId() throws Except new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(endpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(endpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Create, ResourceType.Document, collectionName + "/docs", Utils.getUTF8Bytes("content1"), new HashMap<>()); @@ -515,7 +515,7 @@ public void setSessionToken_MergeOldWithNew() throws Exception { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Create, ResourceType.Document, collectionName + "/docs", Utils.getUTF8Bytes("content1"), new HashMap<>()); @@ -576,7 +576,7 @@ public void resolveGlobalSessionTokenReturnsEmptyStringOnCacheMiss() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest collectionCreateRequest = RxDocumentServiceRequest.create( mockDiagnosticsClientContext(), OperationType.Create, ResourceType.DocumentCollection); @@ -608,7 +608,7 @@ public void resolveGlobalSessionTokenReturnsTokenMapUsingName() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -654,7 +654,7 @@ public void resolveGlobalSessionTokenReturnsTokenMapUsingResourceId() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -702,7 +702,7 @@ public void resolveLocalSessionTokenReturnsTokenMapUsingName() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -746,7 +746,7 @@ public void resolveLocalSessionTokenReturnsTokenMapUsingResourceId() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -795,7 +795,7 @@ public void resolveLocalSessionTokenReturnsNullOnPartitionMiss() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -840,7 +840,7 @@ public void resolveLocalSessionTokenReturnsNullOnCollectionMiss() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -883,7 +883,7 @@ public void resolvePartitionLocalSessionTokenReturnsTokenOnParentMatch() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -929,7 +929,7 @@ public void clearTokenByCollectionFullNameRemovesToken() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest documentCollectionCreateRequest = createRequestEntity(OperationType.Create, ResourceType.DocumentCollection, LocationEastUsEndpointToLocationPair.getLeft()); documentCollectionCreateRequest.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -992,7 +992,7 @@ public void clearTokenByResourceIdRemovesToken() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest documentCollectionCreateRequest = createRequestEntity(OperationType.Create, ResourceType.DocumentCollection, LocationEastUsEndpointToLocationPair.getLeft()); documentCollectionCreateRequest.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -1056,7 +1056,7 @@ public void clearTokenKeepsUnmatchedCollection() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest documentCollectionCreateRequest = createRequestEntity(OperationType.Create, ResourceType.DocumentCollection, LocationEastUsEndpointToLocationPair.getLeft()); documentCollectionCreateRequest.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -1115,7 +1115,7 @@ public void setSessionTokenSetsTokenWhenRequestIsntNameBased() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1157,7 +1157,7 @@ public void setSessionTokenGivesPriorityToOwnerFullNameOverResourceAddress() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName1 + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1204,7 +1204,7 @@ public void setSessionTokenIgnoresOwnerIdWhenRequestIsntNameBased() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1258,7 +1258,7 @@ public void setSessionTokenGivesPriorityToOwnerIdOverResourceIdWhenRequestIsName new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.createFromName(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document); @@ -1326,7 +1326,7 @@ public void setSessionTokenDoesntOverwriteHigherLSN() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1374,7 +1374,7 @@ public void setSessionTokenOverwriteLowerLSN() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1421,7 +1421,7 @@ public void setSessionTokenDoesNothingOnEmptySessionTokenHeader() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest docReadRequest1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); docReadRequest1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -1517,7 +1517,7 @@ public void useParentSessionTokenAfterSplit() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); // Set token for the parent String parentPKRangeId = "0"; @@ -1569,7 +1569,7 @@ public void useParentSessionTokenAfterMerge() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); // Set token for the parent // Set tokens for the parents @@ -1697,10 +1697,10 @@ public void resolvePartitionLocalSessionToken( .when(globalEndpointManagerMock.canUseMultipleWriteLocations(Mockito.any())) .thenReturn(true); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationEastUsEndpointToLocationPair.getRight()); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationEastUs2EndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationEastUs2EndpointToLocationPair.getRight()); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationCentralUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationCentralUsEndpointToLocationPair.getRight()); - Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationWestUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationWestUsEndpointToLocationPair.getRight()); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationEastUsEndpointToLocationPair.getRight()); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationEastUs2EndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationEastUs2EndpointToLocationPair.getRight()); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationCentralUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationCentralUsEndpointToLocationPair.getRight()); + Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationWestUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationWestUsEndpointToLocationPair.getRight()); sessionContainer = new RegionScopedSessionContainer(hostName, disableSessionCapturing, globalEndpointManagerMock); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/SessionNotAvailableRetryTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/SessionNotAvailableRetryTest.java index 4847eca2852d..fabf9b43fb53 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/SessionNotAvailableRetryTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/SessionNotAvailableRetryTest.java @@ -191,9 +191,9 @@ public void sessionNotAvailableRetryMultiMaster( String previousContactedRegion = StringUtils.EMPTY; ClientSideRequestStatistics clientSideRequestStatistics = BridgeInternal.getClientSideRequestStatics(ex.getDiagnostics()); for (ClientSideRequestStatistics.StoreResponseStatistics storeResponseStatistics : clientSideRequestStatistics.getResponseStatisticsList()) { - if (!storeResponseStatistics.getRegionName().equalsIgnoreCase(previousContactedRegion)) { - contactedRegions.add(storeResponseStatistics.getRegionName().toLowerCase(Locale.ROOT)); - previousContactedRegion = storeResponseStatistics.getRegionName().toLowerCase(Locale.ROOT); + if (!storeResponseStatistics.getNormalizedRegionName().equalsIgnoreCase(previousContactedRegion)) { + contactedRegions.add(storeResponseStatistics.getNormalizedRegionName().toLowerCase(Locale.ROOT)); + previousContactedRegion = storeResponseStatistics.getNormalizedRegionName().toLowerCase(Locale.ROOT); } } List expectedContactedRegions = new ArrayList<>(); @@ -297,9 +297,9 @@ public void sessionNotAvailableRetrySingleMaster( String previousContactedRegion = StringUtils.EMPTY; ClientSideRequestStatistics clientSideRequestStatistics = BridgeInternal.getClientSideRequestStatics(ex.getDiagnostics()); for (ClientSideRequestStatistics.StoreResponseStatistics storeResponseStatistics : clientSideRequestStatistics.getResponseStatisticsList()) { - if (!storeResponseStatistics.getRegionName().equalsIgnoreCase(previousContactedRegion)) { - contactedRegions.add(storeResponseStatistics.getRegionName().toLowerCase(Locale.ROOT)); - previousContactedRegion = storeResponseStatistics.getRegionName().toLowerCase(Locale.ROOT); + if (!storeResponseStatistics.getNormalizedRegionName().equalsIgnoreCase(previousContactedRegion)) { + contactedRegions.add(storeResponseStatistics.getNormalizedRegionName().toLowerCase(Locale.ROOT)); + previousContactedRegion = storeResponseStatistics.getNormalizedRegionName().toLowerCase(Locale.ROOT); } } 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 4defe74dbe2e..b25bff7463e7 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 @@ -354,7 +354,7 @@ public ISessionToken tryResolveSessionToken( for (Map.Entry localLsnByRegionEntry : localLsnByRegion.v.entrySet()) { int regionId = localLsnByRegionEntry.getKey(); - String normalizedRegionName = RegionUtils.getRegionName(regionId); + String normalizedRegionName = RegionUtils.getNormalizedRegionName(regionId); // the regionId to normalizedRegionName does not exist if (normalizedRegionName.equals(StringUtils.EMPTY)) { 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 d35befead646..f2493c4b0049 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 @@ -215,7 +215,7 @@ private RegionUtils() { * @param regionId the numeric region ID * @return the normalized name (e.g., "westus3"), or empty string if unknown */ - public static String getRegionName(int regionId) { + public static String getNormalizedRegionName(int regionId) { return REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.getOrDefault(regionId, StringUtils.EMPTY); } From d6e8f2bdd28ca8f4695f34ba1e6038b5aa9d86ac Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 15:46:04 -0400 Subject: [PATCH 34/58] Revert accidental changes to CosmosTracerTest, ChangeFeedState, ChangeFeedStateTest --- .../com/azure/cosmos/CosmosTracerTest.java | 2 +- ...tionWithAvailabilityStrategyTestsBase.java | 4 +- ...titionEndpointManagerForPPCBUnitTests.java | 12 +- .../implementation/ChangeFeedStateTest.java | 269 ++++++++++++++++++ ...ScopedSessionContainerConcurrencyTest.java | 6 +- .../RegionScopedSessionContainerTest.java | 54 ++-- .../SessionNotAvailableRetryTest.java | 12 +- .../PartitionScopedRegionLevelProgress.java | 2 +- .../changefeed/common/ChangeFeedState.java | 148 +++++++--- .../implementation/routing/RegionUtils.java | 2 +- 10 files changed, 418 insertions(+), 93 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosTracerTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosTracerTest.java index b4a7c7bac61a..f8e2979f544d 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosTracerTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosTracerTest.java @@ -1964,7 +1964,7 @@ private void assertStoreResponseStatistics( attributes.put("rntbd.url", storeResultDiagnostics.getStorePhysicalAddressAsString()); attributes.put("rntbd.resource_type", responseStatistics.getRequestResourceType().toString()); attributes.put("rntbd.operation_type", responseStatistics.getRequestOperationType().toString()); - attributes.put("rntbd.region", responseStatistics.getNormalizedRegionName()); + attributes.put("rntbd.region", responseStatistics.getRegionName()); if (storeResultDiagnostics.getLsn() > 0) { attributes.put("rntbd.lsn", Long.toString(storeResultDiagnostics.getLsn())); 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 ebe227072026..006aef08bc69 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 @@ -1804,7 +1804,7 @@ public Object[][] testConfigs_writeAfterCreation() { firstRequestStart = currentStoreResponse.getRequestStartTimeUTC(); } - if (currentStoreResponse.getNormalizedRegionName().equals(SECOND_REGION_NAME) && + if (currentStoreResponse.getRegionName().equals(SECOND_REGION_NAME) && currentStoreResponse.getRequestStartTimeUTC().isBefore(firstRequestStartInSecondRegion)) { firstRequestStartInSecondRegion = currentStoreResponse.getRequestStartTimeUTC(); @@ -1867,7 +1867,7 @@ public Object[][] testConfigs_writeAfterCreation() { firstRequestStart = currentStoreResponse.getRequestStartTimeUTC(); } - if (currentStoreResponse.getNormalizedRegionName().equals(SECOND_REGION_NAME) && + if (currentStoreResponse.getRegionName().equals(SECOND_REGION_NAME) && currentStoreResponse.getRequestStartTimeUTC().isBefore(firstRequestStartInSecondRegion)) { firstRequestStartInSecondRegion = currentStoreResponse.getRequestStartTimeUTC(); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/GlobalPartitionEndpointManagerForPPCBUnitTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/GlobalPartitionEndpointManagerForPPCBUnitTests.java index fdc63e684f5d..cc7d589836eb 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/GlobalPartitionEndpointManagerForPPCBUnitTests.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/GlobalPartitionEndpointManagerForPPCBUnitTests.java @@ -62,22 +62,22 @@ public void beforeClass() { this.globalEndpointManagerMock = Mockito.mock(GlobalEndpointManager.class); Mockito - .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationEastUsEndpointToLocationPair.getKey(), OperationType.Read)) + .when(this.globalEndpointManagerMock.getRegionName(LocationEastUsEndpointToLocationPair.getKey(), OperationType.Read)) .thenReturn(LocationEastUsEndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationEastUsEndpointToLocationPair.getKey(), OperationType.Create)) + .when(this.globalEndpointManagerMock.getRegionName(LocationEastUsEndpointToLocationPair.getKey(), OperationType.Create)) .thenReturn(LocationEastUsEndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationCentralUsEndpointToLocationPair.getKey(), OperationType.Read)) + .when(this.globalEndpointManagerMock.getRegionName(LocationCentralUsEndpointToLocationPair.getKey(), OperationType.Read)) .thenReturn(LocationCentralUsEndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationCentralUsEndpointToLocationPair.getKey(), OperationType.Create)) + .when(this.globalEndpointManagerMock.getRegionName(LocationCentralUsEndpointToLocationPair.getKey(), OperationType.Create)) .thenReturn(LocationCentralUsEndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationEastUs2EndpointToLocationPair.getKey(), OperationType.Read)) + .when(this.globalEndpointManagerMock.getRegionName(LocationEastUs2EndpointToLocationPair.getKey(), OperationType.Read)) .thenReturn(LocationEastUs2EndpointToLocationPair.getRight()); Mockito - .when(this.globalEndpointManagerMock.getNormalizedRegionName(LocationEastUs2EndpointToLocationPair.getKey(), OperationType.Create)) + .when(this.globalEndpointManagerMock.getRegionName(LocationEastUs2EndpointToLocationPair.getKey(), OperationType.Create)) .thenReturn(LocationEastUs2EndpointToLocationPair.getRight()); } diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ChangeFeedStateTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ChangeFeedStateTest.java index 9e23cc5e6781..a9195c9d9f3e 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ChangeFeedStateTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ChangeFeedStateTest.java @@ -17,7 +17,9 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.ArrayList; import java.util.Base64; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -439,4 +441,271 @@ public void changeFeedState_populateRequest( assertThat(headers.get(key)).isEqualTo(expectedHeaders.get(key)); } } + + @Test(groups = "unit") + public void changeFeedState_extractForEffectiveRange_multipleCallsProduceCorrectResults() { + int tokenCount = 100; + ChangeFeedState state = createStateWithManyTokens(tokenCount); + + List> ranges = new ArrayList<>(); + ranges.add(new Range<>(String.format("%06X", 0), String.format("%06X", 10), true, false)); + ranges.add(new Range<>(String.format("%06X", 50), String.format("%06X", 60), true, false)); + ranges.add(new Range<>(String.format("%06X", 90), String.format("%06X", 100), true, false)); + ranges.add(new Range<>(String.format("%06X", 25), String.format("%06X", 26), true, false)); + + List results = state.extractForEffectiveRanges(ranges); + assertThat(results).hasSize(4); + + List tokens1 = results.get(0).extractContinuationTokens(); + assertThat(tokens1).hasSize(10); + for (int i = 0; i < 10; i++) { + assertThat(tokens1.get(i).getToken()).isEqualTo("token_" + i); + } + + List tokens2 = results.get(1).extractContinuationTokens(); + assertThat(tokens2).hasSize(10); + for (int i = 0; i < 10; i++) { + assertThat(tokens2.get(i).getToken()).isEqualTo("token_" + (50 + i)); + } + + List tokens3 = results.get(2).extractContinuationTokens(); + assertThat(tokens3).hasSize(10); + for (int i = 0; i < 10; i++) { + assertThat(tokens3.get(i).getToken()).isEqualTo("token_" + (90 + i)); + } + + List tokens4 = results.get(3).extractContinuationTokens(); + assertThat(tokens4).hasSize(1); + assertThat(tokens4.get(0).getToken()).isEqualTo("token_25"); + } + + @Test(groups = "unit") + public void changeFeedState_extractForEffectiveRanges_singleBatchAndSingleCallParity() { + ChangeFeedState state = createStateWithManyTokens(20); + + List> ranges = new ArrayList<>(); + ranges.add(new Range<>(String.format("%06X", 0), String.format("%06X", 5), true, false)); + ranges.add(new Range<>(String.format("%06X", 10), String.format("%06X", 15), true, false)); + ranges.add(new Range<>(String.format("%06X", 18), String.format("%06X", 20), true, false)); + + List batchResults = state.extractForEffectiveRanges(ranges); + + for (int i = 0; i < ranges.size(); i++) { + ChangeFeedState singleResult = state.extractForEffectiveRange(ranges.get(i)); + assertThat(batchResults.get(i).toString()) + .as("Batch result at index %d should match single-range result", i) + .isEqualTo(singleResult.toString()); + } + } + + @Test(groups = "unit") + public void changeFeedState_extractForEffectiveRange_singleToken() { + String containerRid = "/cols/" + UUID.randomUUID(); + String pkRangeId = UUID.randomUUID().toString(); + FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); + + String continuationJson = String.format( + "{\"V\":1,\"Rid\":\"%s\",\"Continuation\":[" + + "{\"token\":\"tok1\",\"range\":{\"min\":\"AA\",\"max\":\"CC\"}}" + + "],\"PKRangeId\":\"%s\"}", + containerRid, pkRangeId); + + FeedRangeContinuation continuation = FeedRangeContinuation.convert(continuationJson); + ChangeFeedState state = new ChangeFeedStateV1( + containerRid, feedRange, ChangeFeedMode.INCREMENTAL, + ChangeFeedStartFromInternal.createFromNow(), continuation); + + List tokens = + state.extractForEffectiveRange(new Range<>("AA", "CC", true, false)) + .extractContinuationTokens(); + assertThat(tokens).hasSize(1); + assertThat(tokens.get(0).getToken()).isEqualTo("tok1"); + assertThat(tokens.get(0).getRange()).isEqualTo(new Range<>("AA", "CC", true, false)); + + tokens = state.extractForEffectiveRange(new Range<>("AB", "BB", true, false)) + .extractContinuationTokens(); + assertThat(tokens).hasSize(1); + assertThat(tokens.get(0).getToken()).isEqualTo("tok1"); + assertThat(tokens.get(0).getRange()).isEqualTo(new Range<>("AB", "BB", true, false)); + } + + @Test(groups = "unit") + public void changeFeedState_extractForEffectiveRange_lastTokenExactMatch() { + ChangeFeedState state = createStateWithManyTokens(5); + + String min4 = String.format("%06X", 4); + String max5 = String.format("%06X", 5); + List tokens = + state.extractForEffectiveRange(new Range<>(min4, max5, true, false)) + .extractContinuationTokens(); + + assertThat(tokens).hasSize(1); + assertThat(tokens.get(0).getToken()).isEqualTo("token_4"); + } + + @Test(groups = "unit") + public void changeFeedState_extractForEffectiveRange_fullRange() { + int tokenCount = 20; + ChangeFeedState state = createStateWithManyTokens(tokenCount); + + List tokens = state.extractContinuationTokens(); + + assertThat(tokens).hasSize(tokenCount); + for (int i = 0; i < tokenCount; i++) { + assertThat(tokens.get(i).getToken()).isEqualTo("token_" + i); + } + } + + @Test(groups = "unit") + public void changeFeedState_extractForEffectiveRange_partialOverlapAcrossTokenBoundary() { + String continuationAAToCC = "tok_aa_cc"; + String continuationCCToEE = "tok_cc_ee"; + ChangeFeedState state = this.createDefaultStateWithContinuation(continuationAAToCC, continuationCCToEE); + + List tokens = + state.extractForEffectiveRange(new Range<>("BB", "DD", true, false)) + .extractContinuationTokens(); + + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).getRange()).isEqualTo(new Range<>("BB", "CC", true, false)); + assertThat(tokens.get(0).getToken()).isEqualTo(continuationAAToCC); + assertThat(tokens.get(1).getRange()).isEqualTo(new Range<>("CC", "DD", true, false)); + assertThat(tokens.get(1).getToken()).isEqualTo(continuationCCToEE); + } + + @Test(groups = "unit", expectedExceptions = IllegalArgumentException.class) + public void changeFeedState_extractForEffectiveRange_noOverlapReturnsEmpty() { + ChangeFeedState state = createStateWithManyTokens(5); + state.extractForEffectiveRange(new Range<>("000010", "000011", true, false)); + } + + @Test(groups = "unit") + public void changeFeedState_extractForEffectiveRange_unsortedInput() { + ChangeFeedState state = createStateWithTokenRanges(new String[][] { + {"CC", "DD"}, {"AA", "BB"}, {"EE", "FF"} + }); + + List allTokens = + state.extractForEffectiveRange(new Range<>("AA", "FF", true, false)) + .extractContinuationTokens(); + + assertThat(allTokens).hasSize(3); + assertThat(allTokens.get(0).getRange().getMin()).isEqualTo("AA"); + assertThat(allTokens.get(1).getRange().getMin()).isEqualTo("CC"); + assertThat(allTokens.get(2).getRange().getMin()).isEqualTo("EE"); + + List middleToken = + state.extractForEffectiveRange(new Range<>("CC", "DD", true, false)) + .extractContinuationTokens(); + + assertThat(middleToken).hasSize(1); + assertThat(middleToken.get(0).getRange()).isEqualTo(new Range<>("CC", "DD", true, false)); + } + + @Test(groups = "unit") + public void changeFeedState_extractContinuationTokens_nullContinuation() { + String containerRid = "/cols/" + UUID.randomUUID(); + String pkRangeId = UUID.randomUUID().toString(); + FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); + + ChangeFeedState state = new ChangeFeedStateV1( + containerRid, feedRange, ChangeFeedMode.INCREMENTAL, + ChangeFeedStartFromInternal.createFromNow(), null); + + List tokens = state.extractContinuationTokens(); + assertThat(tokens).isEmpty(); + } + + @Test(groups = "unit", expectedExceptions = IllegalArgumentException.class) + public void changeFeedState_extractForEffectiveRange_nullContinuation_throws() { + String containerRid = "/cols/" + UUID.randomUUID(); + String pkRangeId = UUID.randomUUID().toString(); + FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); + + ChangeFeedState state = new ChangeFeedStateV1( + containerRid, feedRange, ChangeFeedMode.INCREMENTAL, + ChangeFeedStartFromInternal.createFromNow(), null); + + state.extractForEffectiveRange(new Range<>("AA", "BB", true, false)); + } + + @Test(groups = "unit") + public void changeFeedState_extractForEffectiveRange_binarySearchBacktrack() { + // Tokens: [000000,000002), [000002,000004), [000004,000006) + // Query: [000003,000005) — starts mid-way through the second token + // binarySearch returns insertionPoint=2 (negative), so startIndex = Math.max(0, 2-2) = 1 + // This exercises the backtrack path where startIndex > 0 after binary search + ChangeFeedState state = createStateWithTokenRanges(new String[][] { + {"000000", "000002"}, {"000002", "000004"}, {"000004", "000006"} + }); + + List tokens = + state.extractForEffectiveRange(new Range<>("000003", "000005", true, false)) + .extractContinuationTokens(); + + assertThat(tokens).hasSize(2); + assertThat(tokens.get(0).getRange()).isEqualTo(new Range<>("000003", "000004", true, false)); + assertThat(tokens.get(0).getToken()).isEqualTo("tok_1"); + assertThat(tokens.get(1).getRange()).isEqualTo(new Range<>("000004", "000005", true, false)); + assertThat(tokens.get(1).getToken()).isEqualTo("tok_2"); + + // Also verify batch API produces the same result + List batchResults = state.extractForEffectiveRanges( + Collections.singletonList(new Range<>("000003", "000005", true, false))); + assertThat(batchResults).hasSize(1); + assertThat(batchResults.get(0).toString()) + .isEqualTo(state.extractForEffectiveRange(new Range<>("000003", "000005", true, false)).toString()); + } + + private ChangeFeedState createStateWithManyTokens(int tokenCount) { + String containerRid = "/cols/" + UUID.randomUUID(); + String pkRangeId = UUID.randomUUID().toString(); + FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); + + StringBuilder continuationEntries = new StringBuilder(); + for (int i = 0; i < tokenCount; i++) { + if (i > 0) { + continuationEntries.append(","); + } + String min = String.format("%06X", i); + String max = String.format("%06X", i + 1); + continuationEntries.append( + String.format("{\"token\":\"token_%d\",\"range\":{\"min\":\"%s\",\"max\":\"%s\"}}", i, min, max)); + } + + String continuationJson = String.format( + "{\"V\":1,\"Rid\":\"%s\",\"Continuation\":[%s],\"PKRangeId\":\"%s\"}", + containerRid, continuationEntries, pkRangeId); + + FeedRangeContinuation continuation = FeedRangeContinuation.convert(continuationJson); + return new ChangeFeedStateV1( + containerRid, feedRange, ChangeFeedMode.INCREMENTAL, + ChangeFeedStartFromInternal.createFromNow(), continuation); + } + + private ChangeFeedState createStateWithTokenRanges(String[][] tokenRanges) { + String containerRid = "/cols/" + UUID.randomUUID(); + String pkRangeId = UUID.randomUUID().toString(); + FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); + + StringBuilder entries = new StringBuilder(); + for (int i = 0; i < tokenRanges.length; i++) { + if (i > 0) { + entries.append(","); + } + entries.append(String.format( + "{\"token\":\"tok_%d\",\"range\":{\"min\":\"%s\",\"max\":\"%s\"}}", + i, tokenRanges[i][0], tokenRanges[i][1])); + } + + String continuationJson = String.format( + "{\"V\":1,\"Rid\":\"%s\",\"Continuation\":[%s],\"PKRangeId\":\"%s\"}", + containerRid, entries, pkRangeId); + + FeedRangeContinuation continuation = FeedRangeContinuation.convert(continuationJson); + return new ChangeFeedStateV1( + containerRid, feedRange, ChangeFeedMode.INCREMENTAL, + ChangeFeedStartFromInternal.createFromNow(), continuation); + } + } diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerConcurrencyTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerConcurrencyTest.java index 87ec54c7f97a..2512f9bd3508 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerConcurrencyTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerConcurrencyTest.java @@ -257,11 +257,11 @@ public void concurrentSetAndResolveTokens(String profileName, new UnmodifiableList<>(endpointBuilder.build()); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(EAST_US), Mockito.any())) + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(EAST_US), Mockito.any())) .thenReturn(REGION_EAST_US); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(EAST_US2), Mockito.any())) + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(EAST_US2), Mockito.any())) .thenReturn(REGION_EAST_US2); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(CENTRAL_US), Mockito.any())) + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(CENTRAL_US), Mockito.any())) .thenReturn(REGION_CENTRAL_US); Mockito.when(globalEndpointManagerMock.canUseMultipleWriteLocations(Mockito.any())).thenReturn(true); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerTest.java index 053444906e5e..d1fb33e71dae 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionScopedSessionContainerTest.java @@ -384,7 +384,7 @@ public void sessionContainer() throws Exception { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(regionContacted); for (int i = 0; i < numCollections; i++) { String collectionResourceId = @@ -448,7 +448,7 @@ public void setSessionToken_NoSessionTokenForPartitionKeyRangeId() throws Except new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(endpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(endpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Create, ResourceType.Document, collectionName + "/docs", Utils.getUTF8Bytes("content1"), new HashMap<>()); @@ -515,7 +515,7 @@ public void setSessionToken_MergeOldWithNew() throws Exception { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Create, ResourceType.Document, collectionName + "/docs", Utils.getUTF8Bytes("content1"), new HashMap<>()); @@ -576,7 +576,7 @@ public void resolveGlobalSessionTokenReturnsEmptyStringOnCacheMiss() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest collectionCreateRequest = RxDocumentServiceRequest.create( mockDiagnosticsClientContext(), OperationType.Create, ResourceType.DocumentCollection); @@ -608,7 +608,7 @@ public void resolveGlobalSessionTokenReturnsTokenMapUsingName() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -654,7 +654,7 @@ public void resolveGlobalSessionTokenReturnsTokenMapUsingResourceId() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -702,7 +702,7 @@ public void resolveLocalSessionTokenReturnsTokenMapUsingName() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -746,7 +746,7 @@ public void resolveLocalSessionTokenReturnsTokenMapUsingResourceId() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -795,7 +795,7 @@ public void resolveLocalSessionTokenReturnsNullOnPartitionMiss() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -840,7 +840,7 @@ public void resolveLocalSessionTokenReturnsNullOnCollectionMiss() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -883,7 +883,7 @@ public void resolvePartitionLocalSessionTokenReturnsTokenOnParentMatch() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -929,7 +929,7 @@ public void clearTokenByCollectionFullNameRemovesToken() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest documentCollectionCreateRequest = createRequestEntity(OperationType.Create, ResourceType.DocumentCollection, LocationEastUsEndpointToLocationPair.getLeft()); documentCollectionCreateRequest.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -992,7 +992,7 @@ public void clearTokenByResourceIdRemovesToken() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest documentCollectionCreateRequest = createRequestEntity(OperationType.Create, ResourceType.DocumentCollection, LocationEastUsEndpointToLocationPair.getLeft()); documentCollectionCreateRequest.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -1056,7 +1056,7 @@ public void clearTokenKeepsUnmatchedCollection() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest documentCollectionCreateRequest = createRequestEntity(OperationType.Create, ResourceType.DocumentCollection, LocationEastUsEndpointToLocationPair.getLeft()); documentCollectionCreateRequest.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -1115,7 +1115,7 @@ public void setSessionTokenSetsTokenWhenRequestIsntNameBased() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1157,7 +1157,7 @@ public void setSessionTokenGivesPriorityToOwnerFullNameOverResourceAddress() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName1 + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1204,7 +1204,7 @@ public void setSessionTokenIgnoresOwnerIdWhenRequestIsntNameBased() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1258,7 +1258,7 @@ public void setSessionTokenGivesPriorityToOwnerIdOverResourceIdWhenRequestIsName new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.createFromName(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document); @@ -1326,7 +1326,7 @@ public void setSessionTokenDoesntOverwriteHigherLSN() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1374,7 +1374,7 @@ public void setSessionTokenOverwriteLowerLSN() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest request = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(),OperationType.Read, collectionFullName + "/docs/doc1", ResourceType.Document, new HashMap<>()); @@ -1421,7 +1421,7 @@ public void setSessionTokenDoesNothingOnEmptySessionTokenHeader() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); RxDocumentServiceRequest docReadRequest1 = RxDocumentServiceRequest.create(mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); docReadRequest1.requestContext.regionalRoutingContextToRoute = new RegionalRoutingContext(locationEndpointContacted); @@ -1517,7 +1517,7 @@ public void useParentSessionTokenAfterSplit() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); // Set token for the parent String parentPKRangeId = "0"; @@ -1569,7 +1569,7 @@ public void useParentSessionTokenAfterMerge() { new RegionalRoutingContext(LocationCentralUsEndpointToLocationPair.getLeft()))); Mockito.when(globalEndpointManagerMock.getReadEndpoints()).thenReturn(endpoints); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(locationEndpointContacted), Mockito.any())).thenReturn(regionContacted); // Set token for the parent // Set tokens for the parents @@ -1697,10 +1697,10 @@ public void resolvePartitionLocalSessionToken( .when(globalEndpointManagerMock.canUseMultipleWriteLocations(Mockito.any())) .thenReturn(true); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationEastUsEndpointToLocationPair.getRight()); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationEastUs2EndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationEastUs2EndpointToLocationPair.getRight()); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationCentralUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationCentralUsEndpointToLocationPair.getRight()); - Mockito.when(globalEndpointManagerMock.getNormalizedRegionName(Mockito.eq(LocationWestUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationWestUsEndpointToLocationPair.getRight()); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationEastUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationEastUsEndpointToLocationPair.getRight()); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationEastUs2EndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationEastUs2EndpointToLocationPair.getRight()); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationCentralUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationCentralUsEndpointToLocationPair.getRight()); + Mockito.when(globalEndpointManagerMock.getRegionName(Mockito.eq(LocationWestUsEndpointToLocationPair.getLeft()), Mockito.any())).thenReturn(LocationWestUsEndpointToLocationPair.getRight()); sessionContainer = new RegionScopedSessionContainer(hostName, disableSessionCapturing, globalEndpointManagerMock); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/SessionNotAvailableRetryTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/SessionNotAvailableRetryTest.java index fabf9b43fb53..4847eca2852d 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/SessionNotAvailableRetryTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/SessionNotAvailableRetryTest.java @@ -191,9 +191,9 @@ public void sessionNotAvailableRetryMultiMaster( String previousContactedRegion = StringUtils.EMPTY; ClientSideRequestStatistics clientSideRequestStatistics = BridgeInternal.getClientSideRequestStatics(ex.getDiagnostics()); for (ClientSideRequestStatistics.StoreResponseStatistics storeResponseStatistics : clientSideRequestStatistics.getResponseStatisticsList()) { - if (!storeResponseStatistics.getNormalizedRegionName().equalsIgnoreCase(previousContactedRegion)) { - contactedRegions.add(storeResponseStatistics.getNormalizedRegionName().toLowerCase(Locale.ROOT)); - previousContactedRegion = storeResponseStatistics.getNormalizedRegionName().toLowerCase(Locale.ROOT); + if (!storeResponseStatistics.getRegionName().equalsIgnoreCase(previousContactedRegion)) { + contactedRegions.add(storeResponseStatistics.getRegionName().toLowerCase(Locale.ROOT)); + previousContactedRegion = storeResponseStatistics.getRegionName().toLowerCase(Locale.ROOT); } } List expectedContactedRegions = new ArrayList<>(); @@ -297,9 +297,9 @@ public void sessionNotAvailableRetrySingleMaster( String previousContactedRegion = StringUtils.EMPTY; ClientSideRequestStatistics clientSideRequestStatistics = BridgeInternal.getClientSideRequestStatics(ex.getDiagnostics()); for (ClientSideRequestStatistics.StoreResponseStatistics storeResponseStatistics : clientSideRequestStatistics.getResponseStatisticsList()) { - if (!storeResponseStatistics.getNormalizedRegionName().equalsIgnoreCase(previousContactedRegion)) { - contactedRegions.add(storeResponseStatistics.getNormalizedRegionName().toLowerCase(Locale.ROOT)); - previousContactedRegion = storeResponseStatistics.getNormalizedRegionName().toLowerCase(Locale.ROOT); + if (!storeResponseStatistics.getRegionName().equalsIgnoreCase(previousContactedRegion)) { + contactedRegions.add(storeResponseStatistics.getRegionName().toLowerCase(Locale.ROOT)); + previousContactedRegion = storeResponseStatistics.getRegionName().toLowerCase(Locale.ROOT); } } 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 b25bff7463e7..4defe74dbe2e 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 @@ -354,7 +354,7 @@ public ISessionToken tryResolveSessionToken( for (Map.Entry localLsnByRegionEntry : localLsnByRegion.v.entrySet()) { int regionId = localLsnByRegionEntry.getKey(); - String normalizedRegionName = RegionUtils.getNormalizedRegionName(regionId); + String normalizedRegionName = RegionUtils.getRegionName(regionId); // the regionId to normalizedRegionName does not exist if (normalizedRegionName.equals(StringUtils.EMPTY)) { diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/changefeed/common/ChangeFeedState.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/changefeed/common/ChangeFeedState.java index 727a25586f72..decd1b7b5463 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/changefeed/common/ChangeFeedState.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/changefeed/common/ChangeFeedState.java @@ -91,40 +91,120 @@ public String toString() { public abstract void populateRequest(RxDocumentServiceRequest request, int maxItemCount); public List extractContinuationTokens() { - return extractContinuationTokens(PartitionKeyInternalHelper.FullRange).getLeft(); + return getSortedContinuationTokens(); } - private Pair, Range> extractContinuationTokens( - Range effectiveRange) { - + /** + * Extracts a {@link ChangeFeedState} for a single effective range. + *

+ * For callers that need to extract states for multiple ranges from the same + * continuation, prefer {@link #extractForEffectiveRanges(List)} which sorts + * the continuation tokens once and reuses the sorted list across all ranges. + * + * @param effectiveRange the partition range to extract for + * @return a new {@link ChangeFeedState} scoped to the given range + */ + public ChangeFeedState extractForEffectiveRange(Range effectiveRange) { checkNotNull(effectiveRange); + return extractForEffectiveRanges(Collections.singletonList(effectiveRange)).get(0); + } + + /** + * Extracts a list of {@link ChangeFeedState} instances, one per input effective range. + *

+ * Continuation tokens are sorted once (O(T log T)) and then each range lookup uses + * binary search (O(log T)) to find the starting position, followed by a forward scan + * through contiguous overlapping tokens. This follows the same binary search pattern as + * {@link com.azure.cosmos.implementation.routing.InMemoryCollectionRoutingMap#getOverlappingRanges} + * and assumes non-overlapping, contiguous partition ranges (Cosmos DB contract). + *

+ * The returned list preserves the input order: result.get(i) corresponds to + * effectiveRanges.get(i). + * + * @param effectiveRanges the list of partition ranges to extract for + * @return a list of {@link ChangeFeedState}, one per input range, in the same order + */ + public List extractForEffectiveRanges(List> effectiveRanges) { + checkNotNull(effectiveRanges, "Argument 'effectiveRanges' must not be null."); + checkArgument(!effectiveRanges.isEmpty(), "Argument 'effectiveRanges' must not be empty."); + + List sortedTokens = getSortedContinuationTokens(); + + List results = new ArrayList<>(effectiveRanges.size()); + for (Range effectiveRange : effectiveRanges) { + checkNotNull(effectiveRange, "Effective range must not be null."); + + Pair, Range> extracted = + extractContinuationTokensForRange(effectiveRange, sortedTokens); + + List tokens = extracted.getLeft(); + Range totalRange = extracted.getRight(); + + FeedRangeEpkImpl feedRange = new FeedRangeEpkImpl(totalRange); + + results.add(new ChangeFeedStateV1( + this.getContainerRid(), + feedRange, + this.getMode(), + this.getStartFromSettings(), + FeedRangeContinuation.create( + this.getContainerRid(), + feedRange, + tokens + ) + )); + } - List extractedContinuationTokens = new ArrayList<>(); + return results; + } + + private List getSortedContinuationTokens() { FeedRangeContinuation continuation = this.getContinuation(); + if (continuation == null) { + return Collections.emptyList(); + } + + List sortedTokens = new ArrayList<>(); + Collections.addAll(sortedTokens, continuation.getCurrentContinuationTokens()); + sortedTokens.sort(ContinuationTokenRangeComparator.SINGLETON_INSTANCE); + return sortedTokens; + } + + /** + * Finds overlapping continuation tokens for an effective range using binary search + * to locate the starting position, then scanning forward through contiguous overlapping + * tokens. Follows the same pattern as + * {@link com.azure.cosmos.implementation.routing.InMemoryCollectionRoutingMap#getOverlappingRanges}. + */ + private Pair, Range> extractContinuationTokensForRange( + Range effectiveRange, + List sortedTokens) { + + List extractedTokens = new ArrayList<>(); String min = null; String max = null; - if (continuation != null) { - List continuationTokensSnapshot = new ArrayList<>(); - Collections.addAll(continuationTokensSnapshot, continuation.getCurrentContinuationTokens()); - continuationTokensSnapshot.sort(ContinuationTokenRangeComparator.SINGLETON_INSTANCE); - - for (CompositeContinuationToken compositeContinuationToken : continuationTokensSnapshot) { - if (Range.checkOverlapping(effectiveRange, compositeContinuationToken.getRange())) { - Range overlappingRange = - getOverlappingRange(effectiveRange, compositeContinuationToken.getRange()); - extractedContinuationTokens.add( - new CompositeContinuationToken(compositeContinuationToken.getToken(), - overlappingRange)); + if (!sortedTokens.isEmpty()) { + int startIndex = Collections.binarySearch( + sortedTokens, + new CompositeContinuationToken(null, effectiveRange), + ContinuationTokenRangeComparator.SINGLETON_INSTANCE); + if (startIndex < 0) { + startIndex = Math.max(0, -startIndex - 2); + } + for (int i = startIndex; i < sortedTokens.size(); i++) { + CompositeContinuationToken token = sortedTokens.get(i); + if (Range.checkOverlapping(effectiveRange, token.getRange())) { + Range overlappingRange = getOverlappingRange(effectiveRange, token.getRange()); + extractedTokens.add(new CompositeContinuationToken( + token.getToken(), overlappingRange)); if (min == null) { min = overlappingRange.getMin(); } max = overlappingRange.getMax(); - } else { - if (extractedContinuationTokens.size() > 0) { - break; - } + } else if (!extractedTokens.isEmpty()) { + break; } } } @@ -135,31 +215,7 @@ private Pair, Range> extractContinuatio true, false); - return Pair.of(extractedContinuationTokens, totalRange); - } - - public ChangeFeedState extractForEffectiveRange(Range effectiveRange) { - checkNotNull(effectiveRange); - - Pair, Range> effectiveTokensAndMinMax = - this.extractContinuationTokens(effectiveRange); - - List extractedContinuationTokens = effectiveTokensAndMinMax.getLeft(); - Range totalRange = effectiveTokensAndMinMax.getRight(); - - FeedRangeEpkImpl feedRange = new FeedRangeEpkImpl(totalRange); - - return new ChangeFeedStateV1( - this.getContainerRid(), - feedRange, - this.getMode(), - this.getStartFromSettings(), - FeedRangeContinuation.create( - this.getContainerRid(), - feedRange, - extractedContinuationTokens - ) - ); + return Pair.of(extractedTokens, totalRange); } private Range getOverlappingRange(Range left, Range right) { 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 f2493c4b0049..d35befead646 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 @@ -215,7 +215,7 @@ private RegionUtils() { * @param regionId the numeric region ID * @return the normalized name (e.g., "westus3"), or empty string if unknown */ - public static String getNormalizedRegionName(int regionId) { + public static String getRegionName(int regionId) { return REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.getOrDefault(regionId, StringUtils.EMPTY); } From d9c3cff8fbb012aca27f2868f52897105f01c38a Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 15:48:43 -0400 Subject: [PATCH 35/58] Refactoring --- .../implementation/PartitionScopedRegionLevelProgress.java | 2 +- .../com/azure/cosmos/implementation/routing/RegionUtils.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 4defe74dbe2e..b25bff7463e7 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 @@ -354,7 +354,7 @@ public ISessionToken tryResolveSessionToken( for (Map.Entry localLsnByRegionEntry : localLsnByRegion.v.entrySet()) { int regionId = localLsnByRegionEntry.getKey(); - String normalizedRegionName = RegionUtils.getRegionName(regionId); + String normalizedRegionName = RegionUtils.getNormalizedRegionName(regionId); // the regionId to normalizedRegionName does not exist if (normalizedRegionName.equals(StringUtils.EMPTY)) { 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 d35befead646..f2493c4b0049 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 @@ -215,7 +215,7 @@ private RegionUtils() { * @param regionId the numeric region ID * @return the normalized name (e.g., "westus3"), or empty string if unknown */ - public static String getRegionName(int regionId) { + public static String getNormalizedRegionName(int regionId) { return REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.getOrDefault(regionId, StringUtils.EMPTY); } From 348701c97832ca53bac9326da994b6f65b898bd0 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 15:50:51 -0400 Subject: [PATCH 36/58] Refactoring --- .../implementation/ChangeFeedStateTest.java | 269 ------------------ .../changefeed/common/ChangeFeedState.java | 148 +++------- 2 files changed, 46 insertions(+), 371 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ChangeFeedStateTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ChangeFeedStateTest.java index a9195c9d9f3e..9e23cc5e6781 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ChangeFeedStateTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ChangeFeedStateTest.java @@ -17,9 +17,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; -import java.util.ArrayList; import java.util.Base64; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -441,271 +439,4 @@ public void changeFeedState_populateRequest( assertThat(headers.get(key)).isEqualTo(expectedHeaders.get(key)); } } - - @Test(groups = "unit") - public void changeFeedState_extractForEffectiveRange_multipleCallsProduceCorrectResults() { - int tokenCount = 100; - ChangeFeedState state = createStateWithManyTokens(tokenCount); - - List> ranges = new ArrayList<>(); - ranges.add(new Range<>(String.format("%06X", 0), String.format("%06X", 10), true, false)); - ranges.add(new Range<>(String.format("%06X", 50), String.format("%06X", 60), true, false)); - ranges.add(new Range<>(String.format("%06X", 90), String.format("%06X", 100), true, false)); - ranges.add(new Range<>(String.format("%06X", 25), String.format("%06X", 26), true, false)); - - List results = state.extractForEffectiveRanges(ranges); - assertThat(results).hasSize(4); - - List tokens1 = results.get(0).extractContinuationTokens(); - assertThat(tokens1).hasSize(10); - for (int i = 0; i < 10; i++) { - assertThat(tokens1.get(i).getToken()).isEqualTo("token_" + i); - } - - List tokens2 = results.get(1).extractContinuationTokens(); - assertThat(tokens2).hasSize(10); - for (int i = 0; i < 10; i++) { - assertThat(tokens2.get(i).getToken()).isEqualTo("token_" + (50 + i)); - } - - List tokens3 = results.get(2).extractContinuationTokens(); - assertThat(tokens3).hasSize(10); - for (int i = 0; i < 10; i++) { - assertThat(tokens3.get(i).getToken()).isEqualTo("token_" + (90 + i)); - } - - List tokens4 = results.get(3).extractContinuationTokens(); - assertThat(tokens4).hasSize(1); - assertThat(tokens4.get(0).getToken()).isEqualTo("token_25"); - } - - @Test(groups = "unit") - public void changeFeedState_extractForEffectiveRanges_singleBatchAndSingleCallParity() { - ChangeFeedState state = createStateWithManyTokens(20); - - List> ranges = new ArrayList<>(); - ranges.add(new Range<>(String.format("%06X", 0), String.format("%06X", 5), true, false)); - ranges.add(new Range<>(String.format("%06X", 10), String.format("%06X", 15), true, false)); - ranges.add(new Range<>(String.format("%06X", 18), String.format("%06X", 20), true, false)); - - List batchResults = state.extractForEffectiveRanges(ranges); - - for (int i = 0; i < ranges.size(); i++) { - ChangeFeedState singleResult = state.extractForEffectiveRange(ranges.get(i)); - assertThat(batchResults.get(i).toString()) - .as("Batch result at index %d should match single-range result", i) - .isEqualTo(singleResult.toString()); - } - } - - @Test(groups = "unit") - public void changeFeedState_extractForEffectiveRange_singleToken() { - String containerRid = "/cols/" + UUID.randomUUID(); - String pkRangeId = UUID.randomUUID().toString(); - FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); - - String continuationJson = String.format( - "{\"V\":1,\"Rid\":\"%s\",\"Continuation\":[" + - "{\"token\":\"tok1\",\"range\":{\"min\":\"AA\",\"max\":\"CC\"}}" + - "],\"PKRangeId\":\"%s\"}", - containerRid, pkRangeId); - - FeedRangeContinuation continuation = FeedRangeContinuation.convert(continuationJson); - ChangeFeedState state = new ChangeFeedStateV1( - containerRid, feedRange, ChangeFeedMode.INCREMENTAL, - ChangeFeedStartFromInternal.createFromNow(), continuation); - - List tokens = - state.extractForEffectiveRange(new Range<>("AA", "CC", true, false)) - .extractContinuationTokens(); - assertThat(tokens).hasSize(1); - assertThat(tokens.get(0).getToken()).isEqualTo("tok1"); - assertThat(tokens.get(0).getRange()).isEqualTo(new Range<>("AA", "CC", true, false)); - - tokens = state.extractForEffectiveRange(new Range<>("AB", "BB", true, false)) - .extractContinuationTokens(); - assertThat(tokens).hasSize(1); - assertThat(tokens.get(0).getToken()).isEqualTo("tok1"); - assertThat(tokens.get(0).getRange()).isEqualTo(new Range<>("AB", "BB", true, false)); - } - - @Test(groups = "unit") - public void changeFeedState_extractForEffectiveRange_lastTokenExactMatch() { - ChangeFeedState state = createStateWithManyTokens(5); - - String min4 = String.format("%06X", 4); - String max5 = String.format("%06X", 5); - List tokens = - state.extractForEffectiveRange(new Range<>(min4, max5, true, false)) - .extractContinuationTokens(); - - assertThat(tokens).hasSize(1); - assertThat(tokens.get(0).getToken()).isEqualTo("token_4"); - } - - @Test(groups = "unit") - public void changeFeedState_extractForEffectiveRange_fullRange() { - int tokenCount = 20; - ChangeFeedState state = createStateWithManyTokens(tokenCount); - - List tokens = state.extractContinuationTokens(); - - assertThat(tokens).hasSize(tokenCount); - for (int i = 0; i < tokenCount; i++) { - assertThat(tokens.get(i).getToken()).isEqualTo("token_" + i); - } - } - - @Test(groups = "unit") - public void changeFeedState_extractForEffectiveRange_partialOverlapAcrossTokenBoundary() { - String continuationAAToCC = "tok_aa_cc"; - String continuationCCToEE = "tok_cc_ee"; - ChangeFeedState state = this.createDefaultStateWithContinuation(continuationAAToCC, continuationCCToEE); - - List tokens = - state.extractForEffectiveRange(new Range<>("BB", "DD", true, false)) - .extractContinuationTokens(); - - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).getRange()).isEqualTo(new Range<>("BB", "CC", true, false)); - assertThat(tokens.get(0).getToken()).isEqualTo(continuationAAToCC); - assertThat(tokens.get(1).getRange()).isEqualTo(new Range<>("CC", "DD", true, false)); - assertThat(tokens.get(1).getToken()).isEqualTo(continuationCCToEE); - } - - @Test(groups = "unit", expectedExceptions = IllegalArgumentException.class) - public void changeFeedState_extractForEffectiveRange_noOverlapReturnsEmpty() { - ChangeFeedState state = createStateWithManyTokens(5); - state.extractForEffectiveRange(new Range<>("000010", "000011", true, false)); - } - - @Test(groups = "unit") - public void changeFeedState_extractForEffectiveRange_unsortedInput() { - ChangeFeedState state = createStateWithTokenRanges(new String[][] { - {"CC", "DD"}, {"AA", "BB"}, {"EE", "FF"} - }); - - List allTokens = - state.extractForEffectiveRange(new Range<>("AA", "FF", true, false)) - .extractContinuationTokens(); - - assertThat(allTokens).hasSize(3); - assertThat(allTokens.get(0).getRange().getMin()).isEqualTo("AA"); - assertThat(allTokens.get(1).getRange().getMin()).isEqualTo("CC"); - assertThat(allTokens.get(2).getRange().getMin()).isEqualTo("EE"); - - List middleToken = - state.extractForEffectiveRange(new Range<>("CC", "DD", true, false)) - .extractContinuationTokens(); - - assertThat(middleToken).hasSize(1); - assertThat(middleToken.get(0).getRange()).isEqualTo(new Range<>("CC", "DD", true, false)); - } - - @Test(groups = "unit") - public void changeFeedState_extractContinuationTokens_nullContinuation() { - String containerRid = "/cols/" + UUID.randomUUID(); - String pkRangeId = UUID.randomUUID().toString(); - FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); - - ChangeFeedState state = new ChangeFeedStateV1( - containerRid, feedRange, ChangeFeedMode.INCREMENTAL, - ChangeFeedStartFromInternal.createFromNow(), null); - - List tokens = state.extractContinuationTokens(); - assertThat(tokens).isEmpty(); - } - - @Test(groups = "unit", expectedExceptions = IllegalArgumentException.class) - public void changeFeedState_extractForEffectiveRange_nullContinuation_throws() { - String containerRid = "/cols/" + UUID.randomUUID(); - String pkRangeId = UUID.randomUUID().toString(); - FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); - - ChangeFeedState state = new ChangeFeedStateV1( - containerRid, feedRange, ChangeFeedMode.INCREMENTAL, - ChangeFeedStartFromInternal.createFromNow(), null); - - state.extractForEffectiveRange(new Range<>("AA", "BB", true, false)); - } - - @Test(groups = "unit") - public void changeFeedState_extractForEffectiveRange_binarySearchBacktrack() { - // Tokens: [000000,000002), [000002,000004), [000004,000006) - // Query: [000003,000005) — starts mid-way through the second token - // binarySearch returns insertionPoint=2 (negative), so startIndex = Math.max(0, 2-2) = 1 - // This exercises the backtrack path where startIndex > 0 after binary search - ChangeFeedState state = createStateWithTokenRanges(new String[][] { - {"000000", "000002"}, {"000002", "000004"}, {"000004", "000006"} - }); - - List tokens = - state.extractForEffectiveRange(new Range<>("000003", "000005", true, false)) - .extractContinuationTokens(); - - assertThat(tokens).hasSize(2); - assertThat(tokens.get(0).getRange()).isEqualTo(new Range<>("000003", "000004", true, false)); - assertThat(tokens.get(0).getToken()).isEqualTo("tok_1"); - assertThat(tokens.get(1).getRange()).isEqualTo(new Range<>("000004", "000005", true, false)); - assertThat(tokens.get(1).getToken()).isEqualTo("tok_2"); - - // Also verify batch API produces the same result - List batchResults = state.extractForEffectiveRanges( - Collections.singletonList(new Range<>("000003", "000005", true, false))); - assertThat(batchResults).hasSize(1); - assertThat(batchResults.get(0).toString()) - .isEqualTo(state.extractForEffectiveRange(new Range<>("000003", "000005", true, false)).toString()); - } - - private ChangeFeedState createStateWithManyTokens(int tokenCount) { - String containerRid = "/cols/" + UUID.randomUUID(); - String pkRangeId = UUID.randomUUID().toString(); - FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); - - StringBuilder continuationEntries = new StringBuilder(); - for (int i = 0; i < tokenCount; i++) { - if (i > 0) { - continuationEntries.append(","); - } - String min = String.format("%06X", i); - String max = String.format("%06X", i + 1); - continuationEntries.append( - String.format("{\"token\":\"token_%d\",\"range\":{\"min\":\"%s\",\"max\":\"%s\"}}", i, min, max)); - } - - String continuationJson = String.format( - "{\"V\":1,\"Rid\":\"%s\",\"Continuation\":[%s],\"PKRangeId\":\"%s\"}", - containerRid, continuationEntries, pkRangeId); - - FeedRangeContinuation continuation = FeedRangeContinuation.convert(continuationJson); - return new ChangeFeedStateV1( - containerRid, feedRange, ChangeFeedMode.INCREMENTAL, - ChangeFeedStartFromInternal.createFromNow(), continuation); - } - - private ChangeFeedState createStateWithTokenRanges(String[][] tokenRanges) { - String containerRid = "/cols/" + UUID.randomUUID(); - String pkRangeId = UUID.randomUUID().toString(); - FeedRangePartitionKeyRangeImpl feedRange = new FeedRangePartitionKeyRangeImpl(pkRangeId); - - StringBuilder entries = new StringBuilder(); - for (int i = 0; i < tokenRanges.length; i++) { - if (i > 0) { - entries.append(","); - } - entries.append(String.format( - "{\"token\":\"tok_%d\",\"range\":{\"min\":\"%s\",\"max\":\"%s\"}}", - i, tokenRanges[i][0], tokenRanges[i][1])); - } - - String continuationJson = String.format( - "{\"V\":1,\"Rid\":\"%s\",\"Continuation\":[%s],\"PKRangeId\":\"%s\"}", - containerRid, entries, pkRangeId); - - FeedRangeContinuation continuation = FeedRangeContinuation.convert(continuationJson); - return new ChangeFeedStateV1( - containerRid, feedRange, ChangeFeedMode.INCREMENTAL, - ChangeFeedStartFromInternal.createFromNow(), continuation); - } - } diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/changefeed/common/ChangeFeedState.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/changefeed/common/ChangeFeedState.java index decd1b7b5463..727a25586f72 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/changefeed/common/ChangeFeedState.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/changefeed/common/ChangeFeedState.java @@ -91,120 +91,40 @@ public String toString() { public abstract void populateRequest(RxDocumentServiceRequest request, int maxItemCount); public List extractContinuationTokens() { - return getSortedContinuationTokens(); + return extractContinuationTokens(PartitionKeyInternalHelper.FullRange).getLeft(); } - /** - * Extracts a {@link ChangeFeedState} for a single effective range. - *

- * For callers that need to extract states for multiple ranges from the same - * continuation, prefer {@link #extractForEffectiveRanges(List)} which sorts - * the continuation tokens once and reuses the sorted list across all ranges. - * - * @param effectiveRange the partition range to extract for - * @return a new {@link ChangeFeedState} scoped to the given range - */ - public ChangeFeedState extractForEffectiveRange(Range effectiveRange) { - checkNotNull(effectiveRange); - return extractForEffectiveRanges(Collections.singletonList(effectiveRange)).get(0); - } - - /** - * Extracts a list of {@link ChangeFeedState} instances, one per input effective range. - *

- * Continuation tokens are sorted once (O(T log T)) and then each range lookup uses - * binary search (O(log T)) to find the starting position, followed by a forward scan - * through contiguous overlapping tokens. This follows the same binary search pattern as - * {@link com.azure.cosmos.implementation.routing.InMemoryCollectionRoutingMap#getOverlappingRanges} - * and assumes non-overlapping, contiguous partition ranges (Cosmos DB contract). - *

- * The returned list preserves the input order: result.get(i) corresponds to - * effectiveRanges.get(i). - * - * @param effectiveRanges the list of partition ranges to extract for - * @return a list of {@link ChangeFeedState}, one per input range, in the same order - */ - public List extractForEffectiveRanges(List> effectiveRanges) { - checkNotNull(effectiveRanges, "Argument 'effectiveRanges' must not be null."); - checkArgument(!effectiveRanges.isEmpty(), "Argument 'effectiveRanges' must not be empty."); - - List sortedTokens = getSortedContinuationTokens(); - - List results = new ArrayList<>(effectiveRanges.size()); - for (Range effectiveRange : effectiveRanges) { - checkNotNull(effectiveRange, "Effective range must not be null."); - - Pair, Range> extracted = - extractContinuationTokensForRange(effectiveRange, sortedTokens); - - List tokens = extracted.getLeft(); - Range totalRange = extracted.getRight(); - - FeedRangeEpkImpl feedRange = new FeedRangeEpkImpl(totalRange); - - results.add(new ChangeFeedStateV1( - this.getContainerRid(), - feedRange, - this.getMode(), - this.getStartFromSettings(), - FeedRangeContinuation.create( - this.getContainerRid(), - feedRange, - tokens - ) - )); - } + private Pair, Range> extractContinuationTokens( + Range effectiveRange) { - return results; - } + checkNotNull(effectiveRange); - private List getSortedContinuationTokens() { + List extractedContinuationTokens = new ArrayList<>(); FeedRangeContinuation continuation = this.getContinuation(); - if (continuation == null) { - return Collections.emptyList(); - } - - List sortedTokens = new ArrayList<>(); - Collections.addAll(sortedTokens, continuation.getCurrentContinuationTokens()); - sortedTokens.sort(ContinuationTokenRangeComparator.SINGLETON_INSTANCE); - return sortedTokens; - } - - /** - * Finds overlapping continuation tokens for an effective range using binary search - * to locate the starting position, then scanning forward through contiguous overlapping - * tokens. Follows the same pattern as - * {@link com.azure.cosmos.implementation.routing.InMemoryCollectionRoutingMap#getOverlappingRanges}. - */ - private Pair, Range> extractContinuationTokensForRange( - Range effectiveRange, - List sortedTokens) { - - List extractedTokens = new ArrayList<>(); String min = null; String max = null; + if (continuation != null) { + List continuationTokensSnapshot = new ArrayList<>(); + Collections.addAll(continuationTokensSnapshot, continuation.getCurrentContinuationTokens()); + continuationTokensSnapshot.sort(ContinuationTokenRangeComparator.SINGLETON_INSTANCE); - if (!sortedTokens.isEmpty()) { - int startIndex = Collections.binarySearch( - sortedTokens, - new CompositeContinuationToken(null, effectiveRange), - ContinuationTokenRangeComparator.SINGLETON_INSTANCE); - if (startIndex < 0) { - startIndex = Math.max(0, -startIndex - 2); - } + for (CompositeContinuationToken compositeContinuationToken : continuationTokensSnapshot) { + if (Range.checkOverlapping(effectiveRange, compositeContinuationToken.getRange())) { + Range overlappingRange = + getOverlappingRange(effectiveRange, compositeContinuationToken.getRange()); + + extractedContinuationTokens.add( + new CompositeContinuationToken(compositeContinuationToken.getToken(), + overlappingRange)); - for (int i = startIndex; i < sortedTokens.size(); i++) { - CompositeContinuationToken token = sortedTokens.get(i); - if (Range.checkOverlapping(effectiveRange, token.getRange())) { - Range overlappingRange = getOverlappingRange(effectiveRange, token.getRange()); - extractedTokens.add(new CompositeContinuationToken( - token.getToken(), overlappingRange)); if (min == null) { min = overlappingRange.getMin(); } max = overlappingRange.getMax(); - } else if (!extractedTokens.isEmpty()) { - break; + } else { + if (extractedContinuationTokens.size() > 0) { + break; + } } } } @@ -215,7 +135,31 @@ private Pair, Range> extractContinuatio true, false); - return Pair.of(extractedTokens, totalRange); + return Pair.of(extractedContinuationTokens, totalRange); + } + + public ChangeFeedState extractForEffectiveRange(Range effectiveRange) { + checkNotNull(effectiveRange); + + Pair, Range> effectiveTokensAndMinMax = + this.extractContinuationTokens(effectiveRange); + + List extractedContinuationTokens = effectiveTokensAndMinMax.getLeft(); + Range totalRange = effectiveTokensAndMinMax.getRight(); + + FeedRangeEpkImpl feedRange = new FeedRangeEpkImpl(totalRange); + + return new ChangeFeedStateV1( + this.getContainerRid(), + feedRange, + this.getMode(), + this.getStartFromSettings(), + FeedRangeContinuation.create( + this.getContainerRid(), + feedRange, + extractedContinuationTokens + ) + ); } private Range getOverlappingRange(Range left, Range right) { From 804cc3f1d3451c1893abd18b8000ebc13939bc56 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 22:06:19 -0400 Subject: [PATCH 37/58] =?UTF-8?q?Remove=20hardcoded=20DIRECT=20connectionT?= =?UTF-8?q?ype=20from=20fault=20injection=20rule=20=E2=80=94=20supports=20?= =?UTF-8?q?both=20DIRECT=20and=20GATEWAY?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java | 1 - 1 file changed, 1 deletion(-) 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 006aef08bc69..59891a38dddd 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 @@ -417,7 +417,6 @@ public void readAfterCreation_nonCanonicalPreferredRegions_shouldRouteCorrectly( FaultInjectionRule faultRule = new FaultInjectionRuleBuilder(ruleName) .condition( new FaultInjectionConditionBuilder() - .connectionType(FaultInjectionConnectionType.DIRECT) .region(this.writeableRegions.get(0)) .operationType(FaultInjectionOperationType.READ_ITEM) .build()) From 3e875caf30e73221fcb0581120f05b86f76aa9fd Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 11 May 2026 22:10:11 -0400 Subject: [PATCH 38/58] Remove hardcoded DIRECT connectionType from PPCB test fault injection rule --- .../com/azure/cosmos/PerPartitionCircuitBreakerE2ETests.java | 1 - 1 file changed, 1 deletion(-) 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 cd48c3487c5b..ab84a7b52724 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 @@ -5320,7 +5320,6 @@ public void nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly() { // Step 1: Inject 503 (ServiceUnavailable) into the first preferred region for READ_ITEM FaultInjectionCondition faultCondition = new FaultInjectionConditionBuilder() - .connectionType(FaultInjectionConnectionType.DIRECT) .region(this.writeRegions.get(0)) .operationType(FaultInjectionOperationType.READ_ITEM) .build(); From 2df0dc54283f2e44778f1eac59ba9e7382678b47 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Tue, 12 May 2026 10:51:54 -0400 Subject: [PATCH 39/58] Fixing tests. --- .../com/azure/cosmos/ExcludeRegionTests.java | 56 +------------------ .../PerPartitionCircuitBreakerE2ETests.java | 3 +- 2 files changed, 3 insertions(+), 56 deletions(-) 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 94ad7b3c9f43..ae631a645fd3 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 @@ -158,7 +158,7 @@ public void excludeRegionTest_SkipFirstPreferredRegion(OperationType operationTy Thread.currentThread().interrupt(); throw new RuntimeException("Interrupted while waiting for replication", ie); } - + try { CosmosDiagnosticsContext diagnostics = this.performDocumentOperation( cosmosAsyncContainer, @@ -327,60 +327,6 @@ public void excludeRegionTest_uppercaseExcludeRegion_shouldSkipExcludedRegion() validateRegionsContacted(diagnostics, this.preferredRegionList.subList(1, 2)); } - @Test(groups = {"multi-master"}, dataProvider = "faultInjectionArgProvider", timeOut = TIMEOUT) - public void excludeRegionTest_nonCanonicalExcludeRegion_withFaultInjection( - OperationType operationType, - FaultInjectionOperationType faultInjectionOperationType) throws InterruptedException { - - if (this.nonCanonicalPreferredRegionList.size() <= 1) { - throw new SkipException("Test requires multi-master with multi-regions"); - } - - // Client built with non-canonical preferred regions (e.g., "westus3") - // Inject 404/1002 into second region, exclude second region using UPPERCASE name - // Verify request routes only to first region (not the excluded second region) - - TestObject createdItem = TestObject.create(); - this.cosmosAsyncContainerNonCanonical.createItem(createdItem).block(); - - Thread.sleep(2000); - - // Inject fault into second region - FaultInjectionRule serverErrorRule = new FaultInjectionRuleBuilder( - "nonCanonicalExclude-fi-" + operationType + "-" + java.util.UUID.randomUUID()) - .condition( - new FaultInjectionConditionBuilder() - .region(this.preferredRegionList.get(1)) - .operationType(faultInjectionOperationType) - .build()) - .result( - FaultInjectionResultBuilders - .getResultBuilder(FaultInjectionServerErrorType.READ_SESSION_NOT_AVAILABLE) - .build() - ).build(); - - try { - CosmosFaultInjectionHelper.configureFaultInjectionRules( - this.cosmosAsyncContainerNonCanonical, Arrays.asList(serverErrorRule)).block(); - - // Exclude the second region using uppercase name (non-canonical) - String secondRegionUppercase = this.preferredRegionList.get(1).toUpperCase(); - - CosmosDiagnosticsContext diagnostics = this.performDocumentOperation( - cosmosAsyncContainerNonCanonical, - operationType, - createdItem, - Arrays.asList(secondRegionUppercase), - INF_E2E_TIMEOUT); - - // Should route only to the first preferred region — second is both - // excluded (non-canonical uppercase) and faulty (fault injection) - validateRegionsContacted(diagnostics, this.preferredRegionList.subList(0, 1)); - } finally { - serverErrorRule.disable(); - } - } - private List getPreferredRegionList(CosmosAsyncClient client) { assertThat(client).isNotNull(); 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 ab84a7b52724..14c98fcd0896 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 @@ -5288,7 +5288,7 @@ public void nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly() { "COSMOS.PARTITION_LEVEL_CIRCUIT_BREAKER_CONFIG", "{\"isPartitionLevelCircuitBreakerEnabled\": true, " + "\"circuitBreakerType\": \"CONSECUTIVE_EXCEPTION_COUNT_BASED\"," - + "\"consecutiveExceptionCountToleratedForReads\": 5," + + "\"consecutiveExceptionCountToleratedForReads\": 10," + "\"consecutiveExceptionCountToleratedForWrites\": 5," + "}"); @@ -5368,6 +5368,7 @@ public void nonCanonicalPreferredRegions_ppcbShouldStillRouteCorrectly() { .isTrue(); } finally { + System.clearProperty("COSMOS.PARTITION_LEVEL_CIRCUIT_BREAKER_CONFIG"); if (asyncClient != null) { asyncClient.close(); } From 0d1e64aff7fea8ea9486ce88e04edc385d72b07a Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Tue, 12 May 2026 11:46:28 -0400 Subject: [PATCH 40/58] Fixing tests. --- .../RegionUtilsSettingsXmlValidationTest.java | 141 ++++++++++++++++++ .../test/resources/region-to-id-settings.xml | 1 + 2 files changed, 142 insertions(+) create mode 100644 sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsSettingsXmlValidationTest.java create mode 100644 sdk/cosmos/azure-cosmos-tests/src/test/resources/region-to-id-settings.xml diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsSettingsXmlValidationTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsSettingsXmlValidationTest.java new file mode 100644 index 000000000000..120cedc49619 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsSettingsXmlValidationTest.java @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.cosmos.implementation.routing; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.annotations.Test; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Validates that {@link RegionUtils#CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS} + * stays in sync with the authoritative regionToIdMapping from Settings.xml. + * + *

The source of truth is a checked-in copy of Settings.xml at + * {@code src/test/resources/region-to-id-settings.xml}, fetched from: + * CosmosDB/Settings.xml + * + *

Update workflow: pull the latest Settings.xml from the CosmosDB repo, + * overwrite {@code region-to-id-settings.xml}, run this test — it will report + * exactly which regions are missing, extra, or have mismatched IDs. + */ +public class RegionUtilsSettingsXmlValidationTest { + + private static final Logger logger = LoggerFactory.getLogger(RegionUtilsSettingsXmlValidationTest.class); + + private static final String SETTINGS_XML_RESOURCE = "region-to-id-settings.xml"; + + // Extracts the regionIdByRegion JSON object from the XML attribute value + private static final Pattern REGION_MAPPING_PATTERN = + Pattern.compile("\"regionIdByRegion\"\\s*:\\s*\\{([^}]+)}"); + + @Test(groups = {"unit"}) + public void regionUtilsMappingMatchesSettingsXml() throws Exception { + + Map settingsXmlMapping = parseRegionToIdMappingFromResource(); + + Map sdkMapping = RegionUtils.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS; + + List errors = new ArrayList<>(); + + // Check every Settings.xml entry exists in RegionUtils with the correct ID + for (Map.Entry expected : settingsXmlMapping.entrySet()) { + String region = expected.getKey(); + int expectedId = expected.getValue(); + + if (!sdkMapping.containsKey(region)) { + errors.add(String.format( + "MISSING in RegionUtils: '%s' (ID=%d) exists in Settings.xml but not in RegionUtils", + region, expectedId)); + } else if (!sdkMapping.get(region).equals(expectedId)) { + errors.add(String.format( + "ID MISMATCH for '%s': Settings.xml has ID=%d, RegionUtils has ID=%d", + region, expectedId, sdkMapping.get(region))); + } + } + + // Check RegionUtils has no extra entries absent from Settings.xml + for (Map.Entry actual : sdkMapping.entrySet()) { + if (!settingsXmlMapping.containsKey(actual.getKey())) { + errors.add(String.format( + "EXTRA in RegionUtils: '%s' (ID=%d) is not in Settings.xml — stale entry?", + actual.getKey(), actual.getValue())); + } + } + + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append("RegionUtils is out of sync with Settings.xml (") + .append(errors.size()).append(" issue(s)):\n"); + for (String error : errors) { + sb.append(" - ").append(error).append("\n"); + } + sb.append("\nFix: update CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS in RegionUtils.java"); + + assertThat(errors).as(sb.toString()).isEmpty(); + } + + logger.info("RegionUtils validated against Settings.xml — {} region mappings match", sdkMapping.size()); + } + + /** + * Parses {@code region-to-id-settings.xml} from test resources and returns + * the regionIdByRegion mapping as a {@code Map}. + */ + private Map parseRegionToIdMappingFromResource() throws Exception { + + InputStream is = getClass().getClassLoader().getResourceAsStream(SETTINGS_XML_RESOURCE); + assertThat(is) + .as("Test resource '%s' not found. Download Settings.xml from the CosmosDB repo " + + "and place it at src/test/resources/%s", SETTINGS_XML_RESOURCE, SETTINGS_XML_RESOURCE) + .isNotNull(); + + // Scan line-by-line for the regionToIdMapping parameter to avoid loading the + // entire 400KB XML into memory + String regionMappingJson = null; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + Matcher matcher = REGION_MAPPING_PATTERN.matcher(line); + if (matcher.find()) { + regionMappingJson = "{" + matcher.group(1) + "}"; + break; + } + } + } + + assertThat(regionMappingJson) + .as("Could not find regionIdByRegion in %s — is the file a valid copy of Settings.xml?", + SETTINGS_XML_RESOURCE) + .isNotNull(); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode node = mapper.readTree(regionMappingJson); + + Map regionToId = new HashMap<>(); + Iterator> fields = node.fields(); + while (fields.hasNext()) { + Map.Entry field = fields.next(); + regionToId.put(field.getKey(), field.getValue().asInt()); + } + + logger.info("Parsed {} region mappings from {}", regionToId.size(), SETTINGS_XML_RESOURCE); + return regionToId; + } +} diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/resources/region-to-id-settings.xml b/sdk/cosmos/azure-cosmos-tests/src/test/resources/region-to-id-settings.xml new file mode 100644 index 000000000000..7adbb3070a90 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/resources/region-to-id-settings.xml @@ -0,0 +1 @@ + From 7983f09a69ea79d35becd47680948a49372b07cc Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 14 May 2026 17:53:36 -0400 Subject: [PATCH 41/58] Normalize hyphens and underscores in region names: west-us-3, west_us_3 now match West US 3 --- .../routing/RegionUtilsNormalizationTest.java | 16 ++++++++++ .../implementation/routing/RegionUtils.java | 30 +++++++++++++------ 2 files changed, 37 insertions(+), 9 deletions(-) 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 fc906040a25c..eff25956dc58 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 @@ -74,6 +74,22 @@ public Object[][] regionNameVariants() { { "mexicocentral", "Mexico Central" }, { "israelcentral", "Israel Central" }, { "newzealandnorth", "New Zealand North" }, + + // Hyphen-separated variants + { "west-us-3", "West US 3" }, + { "east-us", "East US" }, + { "north-europe", "North Europe" }, + { "south-central-us", "South Central US" }, + + // Underscore-separated variants + { "west_us_3", "West US 3" }, + { "east_us", "East US" }, + { "north_europe", "North Europe" }, + { "south_central_us", "South Central US" }, + + // Mixed separators + { "West-US_3", "West US 3" }, + { "EAST_US", "East US" }, }; } 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 f2493c4b0049..be63ce2b587d 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 @@ -190,7 +190,7 @@ public final class RegionUtils { for (Map.Entry entry : CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { String canonicalName = entry.getKey(); // e.g., "West US 3" - String normalizedName = canonicalName.toLowerCase(Locale.ROOT).replace(" ", ""); // e.g., "westus3" + String normalizedName = toNormalizedForm(canonicalName); // e.g., "westus3" if (normalizedToId.put(normalizedName, entry.getValue()) != null) { throw new IllegalStateException("Duplicate normalized region name '" + normalizedName + "' in CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS"); @@ -209,6 +209,17 @@ public final class RegionUtils { private RegionUtils() { } + /** + * Converts a region name to its normalized form: lowercase, with spaces, hyphens, and underscores stripped. + * This is the single normalization function used across all lookup paths. + */ + private static String toNormalizedForm(String regionName) { + return regionName.toLowerCase(Locale.ROOT) + .replace(" ", "") + .replace("-", "") + .replace("_", ""); + } + /** * Returns the normalized name for a region ID. * @@ -237,8 +248,8 @@ public static int getRegionId(String regionName) { if (id != -1) { return id; } - // Slow path: normalize input to lowercase-no-spaces and retry - String normalizedName = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + // Slow path: normalize input to lowercase, strip spaces/hyphens/underscores and retry + String normalizedName = toNormalizedForm(regionName); return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(normalizedName, -1); } @@ -260,8 +271,8 @@ public static String getCanonicalRegionName(String regionName) { return regionName; } - // Normalize to lowercase-no-spaces for map lookup - String normalizedName = regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + // Normalize to lowercase, strip spaces/hyphens/underscores for map lookup + String normalizedName = toNormalizedForm(regionName); // Look up canonical display name String canonicalName = NORMALIZED_TO_CANONICAL.get(normalizedName); @@ -275,25 +286,26 @@ public static String getCanonicalRegionName(String regionName) { } /** - * Returns the normalized form of a region name: lowercase, no spaces. + * Returns the normalized form of a region name: lowercase, no spaces/hyphens/underscores. *

* Examples: *

    *
  • "West US 3" → "westus3"
  • - *
  • "EAST US" → "eastus"
  • + *
  • "EAST-US" → "eastus"
  • + *
  • "east_us_2" → "eastus2"
  • *
  • "Future Region" → "futureregion"
  • *
* Used by {@code LocationHelper} for constructing regional endpoint URLs. * DNS is case-insensitive, so casing doesn't affect resolution. * * @param regionName the region name in any format - * @return the normalized form (lowercase, no spaces) + * @return the normalized form (lowercase, no spaces/hyphens/underscores) */ public static String getNormalizedRegionName(String regionName) { if (StringUtils.isEmpty(regionName)) { return regionName; } - return regionName.toLowerCase(Locale.ROOT).replace(" ", ""); + return toNormalizedForm(regionName); } /** From be5b64447ab684cea8dc19f8db01fd27423e843b Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Wed, 27 May 2026 13:33:03 -0400 Subject: [PATCH 42/58] Add deduped warning for preferred regions that don't match any account region (aligned with Python SDK) --- .../implementation/routing/LocationCache.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 2193f3896594..35ba342a7751 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 @@ -27,10 +27,12 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiFunction; import java.util.function.Supplier; @@ -52,6 +54,7 @@ public class LocationCache { private final Duration unavailableLocationsExpirationTime; private final ConcurrentHashMap locationUnavailabilityInfoByEndpoint; private final ConcurrentHashMap canonicalRegionNameCache; + private final Set warnedUnmatchedRegions; private final ConnectionPolicy connectionPolicy; private DatabaseAccountLocationsInfo locationInfo; @@ -78,6 +81,7 @@ public LocationCache( this.locationUnavailabilityInfoByEndpoint = new ConcurrentHashMap<>(); this.canonicalRegionNameCache = new ConcurrentHashMap<>(); + this.warnedUnmatchedRegions = ConcurrentHashMap.newKeySet(); this.lastCacheUpdateTimestamp = Instant.MIN; this.enableMultipleWriteLocations = false; @@ -832,6 +836,44 @@ private void updateLocationCache( logger.debug("updating location cache finished, new readLocations [{}], new writeLocations [{}]", nextLocationInfo.readRegionalRoutingContexts, nextLocationInfo.writeRegionalRoutingContexts); this.locationInfo = nextLocationInfo; + + // Warn about customer-configured preferred regions that don't match any account region. + // Deduped: each unmatched region is warned only once across refreshes. + this.warnUnmatchedPreferredRegions(nextLocationInfo); + } + } + + private void warnUnmatchedPreferredRegions(DatabaseAccountLocationsInfo locationInfo) { + List customerPreferred = this.connectionPolicy.getPreferredRegions(); + if (customerPreferred == null || customerPreferred.isEmpty()) { + return; + } + + // Collect available region names (normalized for comparison) + Set availableNormalized = new HashSet<>(); + for (String region : locationInfo.availableReadLocations) { + availableNormalized.add(RegionUtils.getNormalizedRegionName(region)); + } + for (String region : locationInfo.availableWriteLocations) { + availableNormalized.add(RegionUtils.getNormalizedRegionName(region)); + } + + for (String preferred : customerPreferred) { + if (preferred == null) { + continue; + } + String normalizedPreferred = RegionUtils.getNormalizedRegionName(preferred); + if (!availableNormalized.contains(normalizedPreferred)) { + // Dedupe: warn only once per unique normalized region + if (this.warnedUnmatchedRegions.add(normalizedPreferred)) { + logger.warn( + "Preferred region '{}' (normalized: '{}') does not match any available account region. " + + "Available regions: {}. This region will be ignored for routing.", + preferred, + normalizedPreferred, + locationInfo.availableReadLocations); + } + } } } From a56a6142b77b7a223b3715bb0c9919631d1925f3 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Wed, 27 May 2026 13:45:30 -0400 Subject: [PATCH 43/58] Improve LocationHelper dataCenterToUriPostfix comment with RFC 4343 rationale --- .../cosmos/implementation/routing/LocationHelper.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java index 8889e6d2eebb..2591d549738f 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java @@ -45,8 +45,11 @@ public static URI getLocationEndpoint(URI serviceEndpoint, String location) { } private static String dataCenterToUriPostfix(String dataCenter) { - // Convert to normalized form (lowercase, no spaces) for use as URL suffix. - // DNS is case-insensitive, so casing doesn't affect resolution. + // Convert to normalized form (lowercase, no spaces/hyphens/underscores) for use as URL suffix. + // DNS is case-insensitive (RFC 4343), so the lowercase change vs. the old space-strip-only + // behavior is invisible to resolution. The hyphen/underscore strips are no-ops on real + // server-returned region names (e.g., "West US 3") but keep this aligned with the single + // toNormalizedForm used across all lookup paths. return RegionUtils.getNormalizedRegionName(dataCenter); } } From 3a7f99a912e2561a5686e3a4d0a19c1a763f7698 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Wed, 27 May 2026 14:45:56 -0400 Subject: [PATCH 44/58] =?UTF-8?q?Fix=20LocationHelperTest=20=E2=80=94=20up?= =?UTF-8?q?date=20expected=20URL=20to=20match=20normalized=20form=20(hyphe?= =?UTF-8?q?ns=20stripped)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cosmos/implementation/LocationHelperTest.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/LocationHelperTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/LocationHelperTest.java index a89451afe107..fb569f02ba8c 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/LocationHelperTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/LocationHelperTest.java @@ -15,8 +15,18 @@ public class LocationHelperTest { @Test(groups = "unit") public void getLocationEndpoint() throws Exception { URI globalServiceEndpoint = URI.create("https://account-name.documents.azure.com:443"); - URI expectedRegionServiceEndpoint = URI.create("https://account-name-east-us.documents.azure.com:443"); + + // Canonical region name with spaces — spaces are stripped for URL suffix + URI expectedWithSpaces = URI.create("https://account-name-eastus.documents.azure.com:443"); + assertThat(LocationHelper.getLocationEndpoint(globalServiceEndpoint, "East US")) + .isEqualTo(expectedWithSpaces); + + // Hyphenated input — hyphens are also stripped (Azure regional DNS uses "eastus" not "east-us") assertThat(LocationHelper.getLocationEndpoint(globalServiceEndpoint, "east-us")) - .isEqualTo(expectedRegionServiceEndpoint); + .isEqualTo(expectedWithSpaces); + + // Already-normalized input + assertThat(LocationHelper.getLocationEndpoint(globalServiceEndpoint, "eastus")) + .isEqualTo(expectedWithSpaces); } } From aa66328f39cee9c666a3b8510d31213df511806d Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Thu, 28 May 2026 21:53:41 -0400 Subject: [PATCH 45/58] Add Spark E2E test verifying region-name-mapper is wired through shaded SDK RegionNameNormalizationE2EITest exercises azure-cosmos-spark_3-5_2-12 and azure-cosmos-spark_4-1_2-13 (same source via copy-shared-test-sources) with non-canonical (lowercase, no spaces) values for spark.cosmos.preferredRegionsList. Two scenarios: - Round-trip: probe with no preferredRegions, capture default region from CosmosDiagnostics, normalize, re-run ingest + read, assert routing pins to the canonical default. - Differential: discover readable + writeable regions from the bootstrap client's GlobalEndpointManager cache, pick a readable region distinct from the probe default, normalize and feed it as preferredRegionsList. Read path asserts contacted region is exactly the canonical secondary (proving the mapper canonicalized the lowercase-no-spaces input); write path asserts strictly only when secondary is also writeable, otherwise expects fallback. Diagnostics are captured via a CosmosDiagnosticsHandler registered through the existing TestCosmosClientBuilderInterceptor + CosmosClientTelemetryConfig.diagnosticsHandler(...) pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../RegionNameNormalizationE2EITest.scala | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 sdk/cosmos/azure-cosmos-spark_3/src/test/scala/com/azure/cosmos/spark/RegionNameNormalizationE2EITest.scala diff --git a/sdk/cosmos/azure-cosmos-spark_3/src/test/scala/com/azure/cosmos/spark/RegionNameNormalizationE2EITest.scala b/sdk/cosmos/azure-cosmos-spark_3/src/test/scala/com/azure/cosmos/spark/RegionNameNormalizationE2EITest.scala new file mode 100644 index 000000000000..8b61c1e72dec --- /dev/null +++ b/sdk/cosmos/azure-cosmos-spark_3/src/test/scala/com/azure/cosmos/spark/RegionNameNormalizationE2EITest.scala @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.cosmos.spark + +import com.azure.core.util.Context +import com.azure.cosmos.implementation.TestConfigurations +import com.azure.cosmos.models.CosmosClientTelemetryConfig +import com.azure.cosmos.spark.diagnostics.BasicLoggingTrait +import com.azure.cosmos.{BridgeInternal, CosmosDiagnosticsContext, CosmosDiagnosticsHandler} + +import java.util.UUID +import java.util.concurrent.ConcurrentLinkedQueue +import scala.collection.JavaConverters._ + +/** + * End-to-end proof that the region-name-mapper feature inside `azure-cosmos` reaches the + * Spark connector through the shaded jar. + * + * Customer scenario: pass non-canonical region names (lowercase, no spaces) via + * `spark.cosmos.preferredRegionsList`. The connector's `CosmosConfig.PreferredRegionRegex` + * accepts that form, so the value reaches `CosmosClientBuilder.preferredRegions(...)` in + * the shaded `azure-cosmos`. Without the region-name-mapper, the SDK's `LocationCache` + * cannot match `westus3` to the canonical `West US 3` account region and routing falls + * back to whatever the default endpoint chooses. + * + * The test discovers the canonical region of the test account dynamically — no hard + * coding. It runs a probe ingest with NO `preferredRegionsList`, parses the diagnostics + * to capture whichever region the SDK contacted by default, then runs the real workload + * with that same region in **non-canonical** lowercase-no-spaces form. The assertion: the + * contacted region after the rename equals the canonical name the probe discovered. If the + * mapper is not wired through the shaded SDK, the second call will either contact a + * different region or none at all and the test fails. + */ +class RegionNameNormalizationE2EITest extends IntegrationSpec + with SparkWithJustDropwizardAndNoSlf4jMetrics + with CosmosClient + with AutoCleanableCosmosContainersWithPkAsPartitionKey + with BasicLoggingTrait { + + //scalastyle:off multiple.string.literals + + "Spark connector with non-canonical preferredRegionsList" should + "route ingest and read traffic to the canonical region discovered via probe" in { + + val newSpark = getSpark + // scalastyle:off underscore.import + import newSpark.implicits._ + // scalastyle:on underscore.import + + val captured = new ConcurrentLinkedQueue[CosmosDiagnosticsContext]() + + TestCosmosClientBuilderInterceptor.setCallback( + builder => { + logInfo("Region-name-mapper E2E: registering capture diagnostics handler.") + builder.clientTelemetryConfig( + new CosmosClientTelemetryConfig() + .diagnosticsHandler(new CapturingDiagnosticsHandler(captured))) + }) + + try { + val baseCfg = Map( + "spark.cosmos.accountEndpoint" -> TestConfigurations.HOST, + "spark.cosmos.accountKey" -> TestConfigurations.MASTER_KEY, + "spark.cosmos.database" -> cosmosDatabase, + "spark.cosmos.container" -> cosmosContainer, + "spark.cosmos.account.clientBuilderInterceptors" -> + "com.azure.cosmos.spark.TestCosmosClientBuilderInterceptor" + ) + + // ----- 1. Probe: ingest a row with NO preferredRegionsList to discover the + // account's default-routed region in canonical form. + val probeDocs = (1 to 3).map(i => (UUID.randomUUID().toString, s"probe-$i")) + probeDocs.toDF("id", "label") + .write.format("cosmos.oltp").mode("Append").options(baseCfg).save() + + val probeContacted = + captured.asScala.toList.flatMap(_.getContactedRegionNames.asScala).toSet + logInfo(s"Region-name-mapper E2E: probe contacted regions = $probeContacted") + probeContacted should not be empty + // The probe must pin to a single canonical region; otherwise we can't form a stable + // expectation for the rename test. + probeContacted.size shouldEqual 1 + val canonicalRegion = probeContacted.head + val nonCanonicalRegion = canonicalRegion.toLowerCase.replace(" ", "") + logInfo( + s"Region-name-mapper E2E: discovered canonical region = '$canonicalRegion', " + + s"feeding non-canonical form '$nonCanonicalRegion' into preferredRegionsList.") + captured.clear() + + val cfg = baseCfg + ("spark.cosmos.preferredRegionsList" -> nonCanonicalRegion) + + // ----- 2. Ingest path with non-canonical preferredRegionsList. + val docs = (1 to 5).map(i => (UUID.randomUUID().toString, s"row-$i")) + docs.toDF("id", "label") + .write.format("cosmos.oltp").mode("Append").options(cfg).save() + + val ingestContacted = + captured.asScala.toList.flatMap(_.getContactedRegionNames.asScala).toSet + logInfo(s"Region-name-mapper E2E: ingest contacted regions = $ingestContacted") + withClue( + s"Ingest must route to canonical '$canonicalRegion' when given non-canonical " + + s"'$nonCanonicalRegion'; got $ingestContacted. Failure implies the " + + s"region-name-mapper is not wired through the shaded SDK.") { + ingestContacted shouldEqual Set(canonicalRegion) + } + captured.clear() + + // ----- 3. Read path with non-canonical preferredRegionsList. + val readDf = newSpark.read.format("cosmos.oltp").options(cfg).load() + val rowCount = readDf.count() + rowCount should be >= docs.size.toLong + + val readContacted = + captured.asScala.toList.flatMap(_.getContactedRegionNames.asScala).toSet + logInfo(s"Region-name-mapper E2E: read contacted regions = $readContacted") + withClue( + s"Read must route to canonical '$canonicalRegion' when given non-canonical " + + s"'$nonCanonicalRegion'; got $readContacted. Failure implies the " + + s"region-name-mapper is not wired through the shaded SDK.") { + readContacted shouldEqual Set(canonicalRegion) + } + } finally { + TestCosmosClientBuilderInterceptor.resetCallback() + } + } + + "Spark connector with non-canonical preferredRegionsList pointing at a NON-primary region" should + "force routing to that secondary region (only meaningful for multi-region accounts)" in { + + // Discover all readable + writeable regions for the test account from the bootstrap + // client's GlobalEndpointManager cache (no network call — the bootstrap client has + // already resolved the account on creation). This is the differential test: the + // previous case fed back the SDK's default region (a round-trip). Here we force traffic + // to MOVE to a secondary region. If the region-name-mapper is not wired through the + // shaded SDK, the lowercase-no-spaces form won't match any account region and routing + // falls back to the primary — the assertion then fails loudly. + val account = BridgeInternal.getContextClient(cosmosClient) + .getGlobalEndpointManager + .getLatestDatabaseAccount + val writeableRegions = account.getWritableLocations.asScala.map(_.getName).toList + val readableRegions = account.getReadableLocations.asScala.map(_.getName).toList + logInfo( + s"Region-name-mapper E2E (secondary): account writeable = $writeableRegions, " + + s"readable = $readableRegions") + + if (readableRegions.size < 2) { + logWarning( + s"Region-name-mapper E2E (secondary): account only has ${readableRegions.size} " + + s"readable region(s); cannot run the secondary-region differential test.") + cancel("Account does not have a secondary readable region.") + } + + val newSpark = getSpark + // scalastyle:off underscore.import + import newSpark.implicits._ + // scalastyle:on underscore.import + + val captured = new ConcurrentLinkedQueue[CosmosDiagnosticsContext]() + + TestCosmosClientBuilderInterceptor.setCallback( + builder => { + logInfo("Region-name-mapper E2E (secondary): registering capture diagnostics handler.") + builder.clientTelemetryConfig( + new CosmosClientTelemetryConfig() + .diagnosticsHandler(new CapturingDiagnosticsHandler(captured))) + }) + + try { + val baseCfg = Map( + "spark.cosmos.accountEndpoint" -> TestConfigurations.HOST, + "spark.cosmos.accountKey" -> TestConfigurations.MASTER_KEY, + "spark.cosmos.database" -> cosmosDatabase, + "spark.cosmos.container" -> cosmosContainer, + "spark.cosmos.account.clientBuilderInterceptors" -> + "com.azure.cosmos.spark.TestCosmosClientBuilderInterceptor" + ) + + // ----- 1. Probe with NO preferredRegionsList to confirm the SDK's default region. + val probeDocs = (1 to 3).map(i => (UUID.randomUUID().toString, s"probe-$i")) + probeDocs.toDF("id", "label") + .write.format("cosmos.oltp").mode("Append").options(baseCfg).save() + val probeContacted = + captured.asScala.toList.flatMap(_.getContactedRegionNames.asScala).toSet + logInfo(s"Region-name-mapper E2E (secondary): probe contacted regions = $probeContacted") + probeContacted.size shouldEqual 1 + val defaultRegionFromDiagnostics = probeContacted.head + captured.clear() + + // ----- 2. Pick a READABLE region distinct from the probe's contacted region (in + // normalized form so the comparison is robust to canonical-vs-lowercase + // differences in the SDK's reported region name). + val canonicalSecondary = readableRegions + .find(r => normalize(r) != normalize(defaultRegionFromDiagnostics)) + .getOrElse( + fail( + s"Could not find a readable region distinct from probe's contacted " + + s"'$defaultRegionFromDiagnostics'; readable = $readableRegions")) + val nonCanonicalSecondary = normalize(canonicalSecondary) + val secondaryIsWriteable = + writeableRegions.exists(r => normalize(r) == nonCanonicalSecondary) + logInfo( + s"Region-name-mapper E2E (secondary): probe default = '$defaultRegionFromDiagnostics', " + + s"selected canonical secondary = '$canonicalSecondary', " + + s"feeding non-canonical form '$nonCanonicalSecondary' into preferredRegionsList " + + s"(secondary writeable = $secondaryIsWriteable).") + + val cfg = baseCfg + ("spark.cosmos.preferredRegionsList" -> nonCanonicalSecondary) + + // ----- 3. Ingest path — only assert region routing if the secondary region is + // writeable; otherwise the SDK MUST fall back to a writeable region (not a + // mapper failure). + val docs = (1 to 5).map(i => (UUID.randomUUID().toString, s"row-$i")) + docs.toDF("id", "label") + .write.format("cosmos.oltp").mode("Append").options(cfg).save() + val ingestContacted = + captured.asScala.toList.flatMap(_.getContactedRegionNames.asScala).toSet + logInfo( + s"Region-name-mapper E2E (secondary): ingest contacted regions = $ingestContacted") + if (secondaryIsWriteable) { + withClue( + s"Ingest must route to canonical secondary '$canonicalSecondary' when given " + + s"non-canonical '$nonCanonicalSecondary'; got $ingestContacted. Routing back " + + s"to primary '$defaultRegionFromDiagnostics' implies the region-name-mapper " + + s"is not wired through the shaded SDK.") { + normalizeSet(ingestContacted) shouldEqual Set(nonCanonicalSecondary) + } + } else { + logInfo( + s"Region-name-mapper E2E (secondary): secondary '$canonicalSecondary' is " + + s"read-only; ingest routed to writeable region(s) $ingestContacted as expected.") + } + captured.clear() + + // ----- 4. Read path — always assert routing to canonical secondary, since reads + // can be served from any readable region. + val readDf = newSpark.read.format("cosmos.oltp").options(cfg).load() + val rowCount = readDf.count() + rowCount should be >= docs.size.toLong + val readContacted = + captured.asScala.toList.flatMap(_.getContactedRegionNames.asScala).toSet + logInfo( + s"Region-name-mapper E2E (secondary): read contacted regions = $readContacted") + withClue( + s"Read must route to canonical secondary '$canonicalSecondary' when given " + + s"non-canonical '$nonCanonicalSecondary'; got $readContacted. Routing back to " + + s"primary '$defaultRegionFromDiagnostics' or any other region implies the " + + s"region-name-mapper is not wired through the shaded SDK.") { + normalizeSet(readContacted) shouldEqual Set(nonCanonicalSecondary) + } + } finally { + TestCosmosClientBuilderInterceptor.resetCallback() + } + } + + private def normalizeSet(regions: Set[String]): Set[String] = + regions.map(normalize) + + private def normalize(region: String): String = + region.toLowerCase.replace(" ", "") + + private class CapturingDiagnosticsHandler( + captured: ConcurrentLinkedQueue[CosmosDiagnosticsContext] + ) extends CosmosDiagnosticsHandler { + override def handleDiagnostics( + diagnosticsContext: CosmosDiagnosticsContext, + traceContext: Context + ): Unit = { + captured.add(diagnosticsContext) + } + } +} From 7bd49d290ca1a0832a61343a8d9ea247cab5d94d Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Fri, 29 May 2026 08:01:05 -0400 Subject: [PATCH 46/58] Address LocationCache review blockers: bounded exclude normalization, null-safe excludes, unknown-region routing parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three blocking issues raised in deep review of the region-name-mapper changes: 1. Unbounded canonicalRegionNameCache (memory leak risk under hostile or dynamic excludeRegions input). Removed the per-LocationCache cache and replaced with a pre-normalize-once-per-call pattern in getApplicableRegionRoutingContexts. The per-request exclude list is normalized once (drops nulls, lowercase, strip spaces/separators) and the per-endpoint check is a plain equals against pre-normalized entries. 2. NPE regression on null exclude region (computeIfAbsent on ConcurrentHashMap throws on null key, whereas the pre-PR equalsIgnoreCase silently returned false). The new pre-normalize loop explicitly skips nulls, restoring the benign behavior. 3. Silent routing failure for unknown regions supplied in no-space form (e.g., customer passes 'plutocentral' against an account whose region is 'Pluto Central'). Added a parallel availableXxxRegionalRoutingContextsBy NormalizedRegionName map keyed by getNormalizedRegionName(serverName). DatabaseAccountLocationsInfo.preferredLocations is now stored in normalized form and looked up against the parallel map in getPreferredAvailableRoutingContexts and shouldRefreshEndpoints. The original availableXxxRegionalRoutingContextsByRegionName map (server-form- lowercased) is unchanged, preserving the contract used by resolveServiceEndpoint, resolveFaultInjectionEndpoint, PPCB internal-exclude lookups, and the public getRegionName diagnostic surface. Also removed warnUnmatchedPreferredRegions and the warnedUnmatchedRegions set field (overengineering — the routing fix above makes the warning redundant). Verified: full azure-cosmos unit suite passes (2469 tests, 0 failures) and the Spark E2E RegionNameNormalizationE2EITest still passes both scenarios against thin-client-multi-region-ci (probe round-trip + secondary-region differential where 'eastus2' input routes reads to canonical 'East US 2'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../implementation/routing/LocationCache.java | 165 +++++++++++------- 1 file changed, 101 insertions(+), 64 deletions(-) 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 35ba342a7751..1e1c3ace2bc8 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 @@ -27,12 +27,10 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiFunction; import java.util.function.Supplier; @@ -53,8 +51,6 @@ public class LocationCache { private final Object lockObject; private final Duration unavailableLocationsExpirationTime; private final ConcurrentHashMap locationUnavailabilityInfoByEndpoint; - private final ConcurrentHashMap canonicalRegionNameCache; - private final Set warnedUnmatchedRegions; private final ConnectionPolicy connectionPolicy; private DatabaseAccountLocationsInfo locationInfo; @@ -80,8 +76,6 @@ public LocationCache( this.lockObject = new Object(); this.locationUnavailabilityInfoByEndpoint = new ConcurrentHashMap<>(); - this.canonicalRegionNameCache = new ConcurrentHashMap<>(); - this.warnedUnmatchedRegions = ConcurrentHashMap.newKeySet(); this.lastCacheUpdateTimestamp = Instant.MIN; this.enableMultipleWriteLocations = false; @@ -351,17 +345,35 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); - // Canonicalize user-configured exclude regions inline using cache to avoid per-request list allocation. - // canonicalRegionNameCache: raw customer string → canonical form (e.g., "westus3" → "West US 3"). + // Pre-normalize user-configured exclude regions once per call (drops nulls). + // Comparison uses normalized form (lowercase + no spaces/hyphens/underscores) so that + // any input variant — including unknown regions in no-space form — matches the server- + // returned region name consistently with how routing resolves preferred regions. + List normalizedUserExcludeRegions; + if (userConfiguredExcludeRegions == null || userConfiguredExcludeRegions.isEmpty()) { + normalizedUserExcludeRegions = Collections.emptyList(); + } else { + normalizedUserExcludeRegions = new ArrayList<>(userConfiguredExcludeRegions.size()); + for (String excludeRegion : userConfiguredExcludeRegions) { + if (excludeRegion != null) { + normalizedUserExcludeRegions.add(RegionUtils.getNormalizedRegionName(excludeRegion)); + } + } + } // 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(excludeRegion -> this.canonicalRegionNameCache - .computeIfAbsent(excludeRegion, RegionUtils::getCanonicalRegionName) - .equalsIgnoreCase(regionName.v))) { + String normalizedRegionName = RegionUtils.getNormalizedRegionName(regionName.v); + boolean isExcluded = false; + for (String normalizedExclude : normalizedUserExcludeRegions) { + if (normalizedExclude.equals(normalizedRegionName)) { + isExcluded = true; + break; + } + } + if (!isExcluded) { applicableEndpoints.add(endpoint); } } @@ -497,6 +509,8 @@ private UnmodifiableList reevaluate( Utils.ValueHolder regionalRoutingContextValueHolder = new Utils.ValueHolder<>(null); + // internalExcludeRegion is server-form (PPCB convention); the forward map + // is keyed by server-form-lowercased, so look it up directly. if (Utils.tryGetValue(regionalRoutingContextsByRegionName, internalExcludeRegion, regionalRoutingContextValueHolder)) { if (!regionalRoutingContextValueHolder.v.equals(hubRoutingContext)) { @@ -511,7 +525,11 @@ private UnmodifiableList reevaluate( Utils.ValueHolder regionalRoutingContextValueHolder = new Utils.ValueHolder<>(null); if (Utils.tryGetValue(regionalRoutingContextsByRegionName, internalExcludeRegion, regionalRoutingContextValueHolder)) { - if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) && !RegionUtils.containsRegionIgnoreCase(userConfiguredExcludeRegions, internalExcludeRegion)) { + // For the user-exclude membership check, compare in normalized form so + // unknown regions in any input form (e.g., "plutocentral" vs "Pluto Central") + // match consistently. + if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) + && !containsNormalizedRegion(userConfiguredExcludeRegions, RegionUtils.getNormalizedRegionName(internalExcludeRegion))) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -523,6 +541,24 @@ private UnmodifiableList reevaluate( return new UnmodifiableList<>(modifiedRegionalRoutingContexts); } + /** + * Checks whether {@code userExcludeRegions} contains {@code normalizedTarget} after normalizing + * each user-supplied entry. Uses the same normalization (lowercase + strip spaces/hyphens/underscores) + * that {@link #getApplicableRegionRoutingContexts} uses for the user-exclude check, so that unknown + * regions in any input form (e.g., "westus3" vs "West US 3") match consistently. + */ + private static boolean containsNormalizedRegion(List userExcludeRegions, String normalizedTarget) { + if (userExcludeRegions == null || userExcludeRegions.isEmpty()) { + return false; + } + for (String region : userExcludeRegions) { + if (region != null && normalizedTarget.equals(RegionUtils.getNormalizedRegionName(region))) { + return true; + } + } + return false; + } + private boolean isExcludeRegionsConfigured(List excludedRegionsOnRequest, List excludedRegionsOnClient) { boolean isExcludedRegionsConfiguredOnRequest = !(excludedRegionsOnRequest == null || excludedRegionsOnRequest.isEmpty()); boolean isExcludedRegionsConfiguredOnClient = !(excludedRegionsOnClient == null || excludedRegionsOnClient.isEmpty()); @@ -578,7 +614,12 @@ public boolean shouldRefreshEndpoints(Utils.ValueHolder canRefreshInBac Utils.ValueHolder mostPreferredReadEndpointHolder = new Utils.ValueHolder<>(); logger.debug("getReadEndpoints [{}]", readLocationEndpoints); - if (Utils.tryGetValue(currentLocationInfo.availableReadRegionalRoutingContextsByRegionName, mostPreferredLocation, mostPreferredReadEndpointHolder)) { + // mostPreferredLocation came from preferredLocations (normalized form) or + // effectivePreferredLocations (server-form). Look up against the parallel + // normalized map after normalizing the key to handle both cases. + String normalizedMostPreferredLocation = RegionUtils.getNormalizedRegionName(mostPreferredLocation); + + if (Utils.tryGetValue(currentLocationInfo.availableReadRegionalRoutingContextsByNormalizedRegionName, normalizedMostPreferredLocation, mostPreferredReadEndpointHolder)) { logger.debug("most preferred is [{}], most preferred available is [{}]", mostPreferredLocation, mostPreferredReadEndpointHolder.v); if (!areEqual(mostPreferredReadEndpointHolder.v, readLocationEndpoints.get(0))) { @@ -620,7 +661,8 @@ public boolean shouldRefreshEndpoints(Utils.ValueHolder canRefreshInBac return shouldRefresh; } } else if (!Strings.isNullOrEmpty(mostPreferredLocation)) { - if (Utils.tryGetValue(currentLocationInfo.availableWriteRegionalRoutingContextsByRegionName, mostPreferredLocation, mostPreferredWriteEndpointHolder)) { + String normalizedMostPreferredLocationForWrite = RegionUtils.getNormalizedRegionName(mostPreferredLocation); + if (Utils.tryGetValue(currentLocationInfo.availableWriteRegionalRoutingContextsByNormalizedRegionName, normalizedMostPreferredLocationForWrite, mostPreferredWriteEndpointHolder)) { shouldRefresh = ! areEqual(mostPreferredWriteEndpointHolder.v,writeLocationEndpoints.get(0)); if (shouldRefresh) { @@ -798,11 +840,13 @@ private void updateLocationCache( Utils.ValueHolder> readLocationsValueHolderOut = Utils.ValueHolder.initialize(nextLocationInfo.availableReadLocations); Utils.ValueHolder> availableReadEndpointsOut = Utils.ValueHolder.initialize(nextLocationInfo.availableReadRegionalRoutingContexts); Utils.ValueHolder> readRegionMapValueHolderOut = Utils.ValueHolder.initialize(nextLocationInfo.regionNameByReadRegionalRoutingContexts); - nextLocationInfo.availableReadRegionalRoutingContextsByRegionName = this.getEndpointsByLocation(gatewayReadLocations, thinClientReadLocations, readLocationsValueHolderOut, availableReadEndpointsOut, readRegionMapValueHolderOut); + Utils.ValueHolder> readEndpointsByNormalizedLocationOut = Utils.ValueHolder.initialize(nextLocationInfo.availableReadRegionalRoutingContextsByNormalizedRegionName); + nextLocationInfo.availableReadRegionalRoutingContextsByRegionName = this.getEndpointsByLocation(gatewayReadLocations, thinClientReadLocations, readLocationsValueHolderOut, availableReadEndpointsOut, readRegionMapValueHolderOut, readEndpointsByNormalizedLocationOut); nextLocationInfo.availableReadLocations = readLocationsValueHolderOut.v; nextLocationInfo.regionNameByReadRegionalRoutingContexts = readRegionMapValueHolderOut.v; nextLocationInfo.availableReadRegionalRoutingContexts = availableReadEndpointsOut.v; + nextLocationInfo.availableReadRegionalRoutingContextsByNormalizedRegionName = readEndpointsByNormalizedLocationOut.v; nextLocationInfo.hubRoutingContext = nextLocationInfo.availableReadRegionalRoutingContexts.get(0); } @@ -810,16 +854,18 @@ private void updateLocationCache( Utils.ValueHolder> writeLocationsValueHolderOut = Utils.ValueHolder.initialize(nextLocationInfo.availableWriteLocations); Utils.ValueHolder> writeRegionMapOut = Utils.ValueHolder.initialize(nextLocationInfo.regionNameByWriteRegionalRoutingContexts); Utils.ValueHolder> availableWriteEndpointsOut = Utils.ValueHolder.initialize(nextLocationInfo.availableWriteRegionalRoutingContexts); + Utils.ValueHolder> writeEndpointsByNormalizedLocationOut = Utils.ValueHolder.initialize(nextLocationInfo.availableWriteRegionalRoutingContextsByNormalizedRegionName); - nextLocationInfo.availableWriteRegionalRoutingContextsByRegionName = this.getEndpointsByLocation(gatewayWriteLocations, thinClientWriteLocations, writeLocationsValueHolderOut, availableWriteEndpointsOut, writeRegionMapOut); + nextLocationInfo.availableWriteRegionalRoutingContextsByRegionName = this.getEndpointsByLocation(gatewayWriteLocations, thinClientWriteLocations, writeLocationsValueHolderOut, availableWriteEndpointsOut, writeRegionMapOut, writeEndpointsByNormalizedLocationOut); nextLocationInfo.availableWriteLocations = writeLocationsValueHolderOut.v; nextLocationInfo.regionNameByWriteRegionalRoutingContexts = writeRegionMapOut.v; nextLocationInfo.availableWriteRegionalRoutingContexts = availableWriteEndpointsOut.v; + nextLocationInfo.availableWriteRegionalRoutingContextsByNormalizedRegionName = writeEndpointsByNormalizedLocationOut.v; nextLocationInfo.hubRoutingContext = nextLocationInfo.availableWriteRegionalRoutingContexts.get(0); } - nextLocationInfo.writeRegionalRoutingContexts = this.getPreferredAvailableRoutingContexts(nextLocationInfo.availableWriteRegionalRoutingContextsByRegionName, nextLocationInfo.availableWriteLocations, OperationType.Write, this.defaultRoutingContext); - nextLocationInfo.readRegionalRoutingContexts = this.getPreferredAvailableRoutingContexts(nextLocationInfo.availableReadRegionalRoutingContextsByRegionName, nextLocationInfo.availableReadLocations, OperationType.Read, nextLocationInfo.writeRegionalRoutingContexts.get(0)); + nextLocationInfo.writeRegionalRoutingContexts = this.getPreferredAvailableRoutingContexts(nextLocationInfo.availableWriteRegionalRoutingContextsByRegionName, nextLocationInfo.availableWriteRegionalRoutingContextsByNormalizedRegionName, nextLocationInfo.availableWriteLocations, OperationType.Write, this.defaultRoutingContext); + nextLocationInfo.readRegionalRoutingContexts = this.getPreferredAvailableRoutingContexts(nextLocationInfo.availableReadRegionalRoutingContextsByRegionName, nextLocationInfo.availableReadRegionalRoutingContextsByNormalizedRegionName, nextLocationInfo.availableReadLocations, OperationType.Read, nextLocationInfo.writeRegionalRoutingContexts.get(0)); if (nextLocationInfo.preferredLocations == null || nextLocationInfo.preferredLocations.isEmpty()) { @@ -836,48 +882,11 @@ private void updateLocationCache( logger.debug("updating location cache finished, new readLocations [{}], new writeLocations [{}]", nextLocationInfo.readRegionalRoutingContexts, nextLocationInfo.writeRegionalRoutingContexts); this.locationInfo = nextLocationInfo; - - // Warn about customer-configured preferred regions that don't match any account region. - // Deduped: each unmatched region is warned only once across refreshes. - this.warnUnmatchedPreferredRegions(nextLocationInfo); - } - } - - private void warnUnmatchedPreferredRegions(DatabaseAccountLocationsInfo locationInfo) { - List customerPreferred = this.connectionPolicy.getPreferredRegions(); - if (customerPreferred == null || customerPreferred.isEmpty()) { - return; - } - - // Collect available region names (normalized for comparison) - Set availableNormalized = new HashSet<>(); - for (String region : locationInfo.availableReadLocations) { - availableNormalized.add(RegionUtils.getNormalizedRegionName(region)); - } - for (String region : locationInfo.availableWriteLocations) { - availableNormalized.add(RegionUtils.getNormalizedRegionName(region)); - } - - for (String preferred : customerPreferred) { - if (preferred == null) { - continue; - } - String normalizedPreferred = RegionUtils.getNormalizedRegionName(preferred); - if (!availableNormalized.contains(normalizedPreferred)) { - // Dedupe: warn only once per unique normalized region - if (this.warnedUnmatchedRegions.add(normalizedPreferred)) { - logger.warn( - "Preferred region '{}' (normalized: '{}') does not match any available account region. " - + "Available regions: {}. This region will be ignored for routing.", - preferred, - normalizedPreferred, - locationInfo.availableReadLocations); - } - } } } private UnmodifiableList getPreferredAvailableRoutingContexts(UnmodifiableMap endpointsByLocation, + UnmodifiableMap endpointsByNormalizedLocation, UnmodifiableList orderedLocations, OperationType expectedAvailableOperation, RegionalRoutingContext fallbackRegionalRoutingContext) { @@ -895,8 +904,11 @@ private UnmodifiableList getPreferredAvailableRoutingCon if (currentLocationInfo.preferredLocations != null && !currentLocationInfo.preferredLocations.isEmpty()) { for (String location: currentLocationInfo.preferredLocations) { + // location is in normalized form (see DatabaseAccountLocationsInfo ctor); + // look it up against the parallel normalized-keyed map so any input form — + // including unknown regions in no-space form — resolves to the right endpoint. Utils.ValueHolder endpoint = new Utils.ValueHolder<>(); - if (Utils.tryGetValue(endpointsByLocation, location, endpoint)) { + if (Utils.tryGetValue(endpointsByNormalizedLocation, location, endpoint)) { if (this.isEndpointUnavailable(endpoint.v, expectedAvailableOperation)) { unavailableEndpoints.add(endpoint.v); } else { @@ -951,6 +963,7 @@ private void addRoutingContexts( Iterable gatewayDbAccountLocations, Iterable thinclientDbAccountLocations, Map endpointsByLocation, + Map endpointsByNormalizedLocation, Map regionByEndpoint, List parsedLocations, List orderedEndpoints) { @@ -968,6 +981,14 @@ private void addRoutingContexts( if (!endpointsByLocation.containsKey(location)) { endpointsByLocation.put(location, regionalRoutingContext); } + // Parallel normalized-form map (lowercase, no spaces/separators) for + // customer preferred/excluded-region routing. Lets unknown regions in + // no-space form like "plutocentral" resolve to the same endpoint as the + // server-form-lowercased entry above. + String normalizedLocation = RegionUtils.getNormalizedRegionName(gatewayDbAccountLocation.getName()); + if (!endpointsByNormalizedLocation.containsKey(normalizedLocation)) { + endpointsByNormalizedLocation.put(normalizedLocation, regionalRoutingContext); + } if (!regionByEndpoint.containsKey(regionalRoutingContext)) { regionByEndpoint.put(regionalRoutingContext, location); @@ -1010,17 +1031,20 @@ private UnmodifiableMap getEndpointsByLocation(I Iterable thinclientLocations, Utils.ValueHolder> orderedLocations, Utils.ValueHolder> orderedEndpointsHolder, - Utils.ValueHolder> regionMap) { + Utils.ValueHolder> regionMap, + Utils.ValueHolder> endpointsByNormalizedLocationHolder) { Map endpointsByLocation = new CaseInsensitiveMap<>(); + Map endpointsByNormalizedLocation = new CaseInsensitiveMap<>(); Map regionByEndpoint = new CaseInsensitiveMap<>(); List parsedLocations = new ArrayList<>(); List orderedEndpoints = new ArrayList<>(); - addRoutingContexts(gatewayLocations, thinclientLocations, endpointsByLocation, regionByEndpoint, parsedLocations, orderedEndpoints); + addRoutingContexts(gatewayLocations, thinclientLocations, endpointsByLocation, endpointsByNormalizedLocation, regionByEndpoint, parsedLocations, orderedEndpoints); orderedLocations.v = new UnmodifiableList<>(parsedLocations); orderedEndpointsHolder.v = new UnmodifiableList<>(orderedEndpoints); regionMap.v = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(regionByEndpoint); + endpointsByNormalizedLocationHolder.v = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(endpointsByNormalizedLocation); return (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(endpointsByLocation); } @@ -1108,6 +1132,12 @@ static class DatabaseAccountLocationsInfo { private UnmodifiableList availableReadLocations; private UnmodifiableMap availableWriteRegionalRoutingContextsByRegionName; private UnmodifiableMap availableReadRegionalRoutingContextsByRegionName; + // Parallel maps keyed by normalized region name (lowercase, no spaces/separators). Used + // for customer-supplied preferred/excluded region lookups so that any input form — + // including unknown regions in no-space form — resolves to the same endpoint as the + // server-form-lowercased entry in the maps above. + private UnmodifiableMap availableWriteRegionalRoutingContextsByNormalizedRegionName; + private UnmodifiableMap availableReadRegionalRoutingContextsByNormalizedRegionName; private UnmodifiableMap regionNameByWriteRegionalRoutingContexts; private UnmodifiableMap regionNameByReadRegionalRoutingContexts; private UnmodifiableList availableWriteRegionalRoutingContexts; @@ -1116,17 +1146,22 @@ static class DatabaseAccountLocationsInfo { public DatabaseAccountLocationsInfo(List preferredLocations, RegionalRoutingContext defaultRoutingContext) { - // Canonicalize each preferred region, then lowercase for CaseInsensitiveMap key matching. - // Canonical = official display form (e.g., "westus3" → "West US 3" → "west us 3"). - // Unknown regions are passed through as-is, then lowercased. + // Normalize each preferred region (lowercase, no spaces/separators). Routing lookups + // hit the parallel availableXxxRegionalRoutingContextsByNormalizedRegionName map, so + // any input variant — "West US 3", "westus3", "WEST-US-3", or unknown regions in + // no-space form like "plutocentral" — resolves consistently. this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream() - .map(loc -> RegionUtils.getCanonicalRegionName(loc).toLowerCase(Locale.ROOT)) + .map(RegionUtils::getNormalizedRegionName) .collect(Collectors.toList())); this.effectivePreferredLocations = new UnmodifiableList<>(Collections.emptyList()); this.availableWriteRegionalRoutingContextsByRegionName = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(new CaseInsensitiveMap<>()); this.availableReadRegionalRoutingContextsByRegionName = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(new CaseInsensitiveMap<>()); + this.availableWriteRegionalRoutingContextsByNormalizedRegionName + = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(new CaseInsensitiveMap<>()); + this.availableReadRegionalRoutingContextsByNormalizedRegionName + = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(new CaseInsensitiveMap<>()); this.regionNameByWriteRegionalRoutingContexts = (UnmodifiableMap) UnmodifiableMap.unmodifiableMap(new CaseInsensitiveMap<>()); this.regionNameByReadRegionalRoutingContexts @@ -1145,6 +1180,8 @@ public DatabaseAccountLocationsInfo(DatabaseAccountLocationsInfo other) { this.availableWriteLocations = other.availableWriteLocations; this.availableReadLocations = other.availableReadLocations; this.availableWriteRegionalRoutingContextsByRegionName = other.availableWriteRegionalRoutingContextsByRegionName; + this.availableWriteRegionalRoutingContextsByNormalizedRegionName = other.availableWriteRegionalRoutingContextsByNormalizedRegionName; + this.availableReadRegionalRoutingContextsByNormalizedRegionName = other.availableReadRegionalRoutingContextsByNormalizedRegionName; this.regionNameByWriteRegionalRoutingContexts = other.regionNameByWriteRegionalRoutingContexts; this.regionNameByReadRegionalRoutingContexts = other.regionNameByReadRegionalRoutingContexts; this.availableReadRegionalRoutingContextsByRegionName = other.availableReadRegionalRoutingContextsByRegionName; From 44bd05dc7c9b3e2bd8182cc84f482c1cb9f6e8b7 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Fri, 29 May 2026 08:56:35 -0400 Subject: [PATCH 47/58] Collapse exclude-region check to single pass with HashSet (O(1) membership) Previous refactor pre-normalized the user-supplied exclude list and then did an inner linear scan per endpoint. Total per-request work: N + N*M (where N = exclude count, M = endpoint count). Replace with a HashSet built once, then a single per-endpoint check using Set.contains(). Total per-request work: N + M (O(1) membership). Semantics unchanged: nulls dropped, normalized comparison on both sides (handles any input form including unknown regions in no-space form). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../implementation/routing/LocationCache.java | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) 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 1e1c3ace2bc8..66b577e4dbd8 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 @@ -27,10 +27,12 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.BiFunction; import java.util.function.Supplier; @@ -345,37 +347,26 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); - // Pre-normalize user-configured exclude regions once per call (drops nulls). - // Comparison uses normalized form (lowercase + no spaces/hyphens/underscores) so that - // any input variant — including unknown regions in no-space form — matches the server- - // returned region name consistently with how routing resolves preferred regions. - List normalizedUserExcludeRegions; + // Normalize user-configured exclude regions into a Set once (drops nulls). + // Normalized form (lowercase + strip spaces/hyphens/underscores) ensures any input + // variant matches the server-form region name, including unknown regions in no-space form. + Set normalizedExcludes; if (userConfiguredExcludeRegions == null || userConfiguredExcludeRegions.isEmpty()) { - normalizedUserExcludeRegions = Collections.emptyList(); + normalizedExcludes = Collections.emptySet(); } else { - normalizedUserExcludeRegions = new ArrayList<>(userConfiguredExcludeRegions.size()); + normalizedExcludes = new HashSet<>(userConfiguredExcludeRegions.size()); for (String excludeRegion : userConfiguredExcludeRegions) { if (excludeRegion != null) { - normalizedUserExcludeRegions.add(RegionUtils.getNormalizedRegionName(excludeRegion)); + normalizedExcludes.add(RegionUtils.getNormalizedRegionName(excludeRegion)); } } } - // exclude those regions which are user excluded first for (RegionalRoutingContext endpoint : regionalRoutingContexts) { Utils.ValueHolder regionName = new Utils.ValueHolder<>(); - if (Utils.tryGetValue(regionNameByRegionalRoutingContext, endpoint, regionName)) { - String normalizedRegionName = RegionUtils.getNormalizedRegionName(regionName.v); - boolean isExcluded = false; - for (String normalizedExclude : normalizedUserExcludeRegions) { - if (normalizedExclude.equals(normalizedRegionName)) { - isExcluded = true; - break; - } - } - if (!isExcluded) { - applicableEndpoints.add(endpoint); - } + if (Utils.tryGetValue(regionNameByRegionalRoutingContext, endpoint, regionName) + && !normalizedExcludes.contains(RegionUtils.getNormalizedRegionName(regionName.v))) { + applicableEndpoints.add(endpoint); } } From 779bbd7281dd8f8563cee15f7f719da1c903a828 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Fri, 29 May 2026 17:37:17 -0400 Subject: [PATCH 48/58] Refactor RegionUtils into pure normalizer + map-aware registry Splits the previously monolithic `RegionUtils` into two classes with disjoint responsibilities: - `RegionNameNormalizer` (pure) - lowercase + strip [-_ ]. Used wherever the SDK needs case- and separator-insensitive equality between two region name strings it already has in hand: `LocationCache` endpoint resolution and `LocationHelper` URL construction. Has no static region map, so a region the SDK has not yet learned about still routes correctly. - `RegionIdRegistry` (map-aware) - the only place that depends on the compiled-in region-id table. Exposes `getRegionId(String)` and `getNormalizedRegionNameForId(int)`. Used only by `PartitionScopedRegionLevelProgress` (session-token region-level progress) and `RxDocumentClientImpl` (multi-region readiness probe). This separation removes a foot-gun where overloaded `getNormalizedRegionName(String)` (pure) and `getNormalizedRegionName(int)` (map-dependent, returns "" for unknown IDs) shared a name. It also frees endpoint resolution from the SDK's compile-time region inventory entirely. Dead-code methods `getCanonicalRegionName`, `canonicalizeRegionNames`, and `containsRegionIgnoreCase` are removed; the only callers were the old `RegionUtilsNormalizationTest`, which is dropped. Renames the surviving tests to match the new class names: - `RegionUtilsTests` -> `RegionIdRegistryTests` - `RegionUtilsSettingsXmlValidationTest` -> `RegionIdRegistrySettingsXmlValidationTest` No behavioral change. `mvn -P unit verify` passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...sTests.java => RegionIdRegistryTests.java} | 17 +- ...nIdRegistrySettingsXmlValidationTest.java} | 26 +- .../routing/RegionUtilsNormalizationTest.java | 178 --------- .../PartitionScopedRegionLevelProgress.java | 8 +- .../implementation/RxDocumentClientImpl.java | 4 +- .../implementation/routing/LocationCache.java | 16 +- .../routing/LocationHelper.java | 4 +- .../routing/RegionIdRegistry.java | 223 +++++++++++ .../routing/RegionNameNormalizer.java | 48 +++ .../implementation/routing/RegionUtils.java | 358 ------------------ 10 files changed, 308 insertions(+), 574 deletions(-) rename sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/{RegionUtilsTests.java => RegionIdRegistryTests.java} (57%) rename sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/{RegionUtilsSettingsXmlValidationTest.java => RegionIdRegistrySettingsXmlValidationTest.java} (83%) delete mode 100644 sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsNormalizationTest.java create mode 100644 sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionIdRegistry.java create mode 100644 sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameNormalizer.java delete mode 100644 sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionUtils.java diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionIdRegistryTests.java similarity index 57% rename from sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java rename to sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionIdRegistryTests.java index 5fa0c5b61a6d..fbdb8aa5ab73 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionUtilsTests.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionIdRegistryTests.java @@ -3,8 +3,7 @@ package com.azure.cosmos.implementation; -import com.azure.cosmos.SessionConsistencyWithRegionScopingTests; -import com.azure.cosmos.implementation.routing.RegionUtils; +import com.azure.cosmos.implementation.routing.RegionIdRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testng.annotations.Test; @@ -14,30 +13,30 @@ import static org.assertj.core.api.Assertions.assertThat; -public class RegionUtilsTests { +public class RegionIdRegistryTests { - private static final Logger logger = LoggerFactory.getLogger(RegionUtils.class); + private static final Logger logger = LoggerFactory.getLogger(RegionIdRegistryTests.class); @Test(groups = {"unit"}) public void regionIdToRegionNameConsistency() { - for (Map.Entry sourceEntry : RegionUtils.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { + for (Map.Entry sourceEntry : RegionIdRegistry.CANONICAL_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(RegionUtils.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.containsKey(normalizedRegionNameFromSource)) + assertThat(RegionIdRegistry.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.containsKey(normalizedRegionNameFromSource)) .as("NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS does not contain key : " + normalizedRegionNameFromSource) .isTrue(); - assertThat(RegionUtils.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.get(normalizedRegionNameFromSource)).isEqualTo(regionIdFromSource); + assertThat(RegionIdRegistry.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.get(normalizedRegionNameFromSource)).isEqualTo(regionIdFromSource); - assertThat(RegionUtils.REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.containsKey(regionIdFromSource)) + assertThat(RegionIdRegistry.REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.containsKey(regionIdFromSource)) .as("REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS does not contain key : " + regionIdFromSource) .isTrue(); - assertThat(RegionUtils.REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.get(regionIdFromSource)).isEqualTo(normalizedRegionNameFromSource); + assertThat(RegionIdRegistry.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/RegionUtilsSettingsXmlValidationTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionIdRegistrySettingsXmlValidationTest.java similarity index 83% rename from sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsSettingsXmlValidationTest.java rename to sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionIdRegistrySettingsXmlValidationTest.java index 120cedc49619..04f04c526f2f 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsSettingsXmlValidationTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionIdRegistrySettingsXmlValidationTest.java @@ -24,7 +24,7 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * Validates that {@link RegionUtils#CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS} + * Validates that {@link RegionIdRegistry#CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS} * stays in sync with the authoritative regionToIdMapping from Settings.xml. * *

The source of truth is a checked-in copy of Settings.xml at @@ -35,9 +35,9 @@ * overwrite {@code region-to-id-settings.xml}, run this test — it will report * exactly which regions are missing, extra, or have mismatched IDs. */ -public class RegionUtilsSettingsXmlValidationTest { +public class RegionIdRegistrySettingsXmlValidationTest { - private static final Logger logger = LoggerFactory.getLogger(RegionUtilsSettingsXmlValidationTest.class); + private static final Logger logger = LoggerFactory.getLogger(RegionIdRegistrySettingsXmlValidationTest.class); private static final String SETTINGS_XML_RESOURCE = "region-to-id-settings.xml"; @@ -46,52 +46,52 @@ public class RegionUtilsSettingsXmlValidationTest { Pattern.compile("\"regionIdByRegion\"\\s*:\\s*\\{([^}]+)}"); @Test(groups = {"unit"}) - public void regionUtilsMappingMatchesSettingsXml() throws Exception { + public void regionIdRegistryMappingMatchesSettingsXml() throws Exception { Map settingsXmlMapping = parseRegionToIdMappingFromResource(); - Map sdkMapping = RegionUtils.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS; + Map sdkMapping = RegionIdRegistry.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS; List errors = new ArrayList<>(); - // Check every Settings.xml entry exists in RegionUtils with the correct ID + // Check every Settings.xml entry exists in RegionIdRegistry with the correct ID for (Map.Entry expected : settingsXmlMapping.entrySet()) { String region = expected.getKey(); int expectedId = expected.getValue(); if (!sdkMapping.containsKey(region)) { errors.add(String.format( - "MISSING in RegionUtils: '%s' (ID=%d) exists in Settings.xml but not in RegionUtils", + "MISSING in RegionIdRegistry: '%s' (ID=%d) exists in Settings.xml but not in RegionIdRegistry", region, expectedId)); } else if (!sdkMapping.get(region).equals(expectedId)) { errors.add(String.format( - "ID MISMATCH for '%s': Settings.xml has ID=%d, RegionUtils has ID=%d", + "ID MISMATCH for '%s': Settings.xml has ID=%d, RegionIdRegistry has ID=%d", region, expectedId, sdkMapping.get(region))); } } - // Check RegionUtils has no extra entries absent from Settings.xml + // Check RegionIdRegistry has no extra entries absent from Settings.xml for (Map.Entry actual : sdkMapping.entrySet()) { if (!settingsXmlMapping.containsKey(actual.getKey())) { errors.add(String.format( - "EXTRA in RegionUtils: '%s' (ID=%d) is not in Settings.xml — stale entry?", + "EXTRA in RegionIdRegistry: '%s' (ID=%d) is not in Settings.xml — stale entry?", actual.getKey(), actual.getValue())); } } if (!errors.isEmpty()) { StringBuilder sb = new StringBuilder(); - sb.append("RegionUtils is out of sync with Settings.xml (") + sb.append("RegionIdRegistry is out of sync with Settings.xml (") .append(errors.size()).append(" issue(s)):\n"); for (String error : errors) { sb.append(" - ").append(error).append("\n"); } - sb.append("\nFix: update CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS in RegionUtils.java"); + sb.append("\nFix: update CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS in RegionIdRegistry.java"); assertThat(errors).as(sb.toString()).isEmpty(); } - logger.info("RegionUtils validated against Settings.xml — {} region mappings match", sdkMapping.size()); + logger.info("RegionIdRegistry validated against Settings.xml — {} region mappings match", sdkMapping.size()); } /** 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 deleted file mode 100644 index eff25956dc58..000000000000 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionUtilsNormalizationTest.java +++ /dev/null @@ -1,178 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.cosmos.implementation.routing; - -import org.testng.annotations.DataProvider; -import org.testng.annotations.Test; - -import java.util.Arrays; -import java.util.Collections; - -import static org.assertj.core.api.Assertions.assertThat; - -/** - * Tests for {@link RegionUtils} - */ -public class RegionUtilsNormalizationTest { - - @DataProvider(name = "regionNameVariants") - public Object[][] regionNameVariants() { - return new Object[][] { - // { input, expected canonical output } - - // Case normalization - { "west us 3", "West US 3" }, - { "WEST US 3", "West US 3" }, - { "West Us 3", "West US 3" }, - { "wEsT uS 3", "West US 3" }, - - // Space-stripped variants (no spaces) - { "westus3", "West US 3" }, - { "WestUS3", "West US 3" }, - { "WESTUS3", "West US 3" }, - - // Already canonical (no-op) - { "West US 3", "West US 3" }, - { "East US", "East US" }, - { "North Europe", "North Europe" }, - { "Central India", "Central India" }, - - // Various regions - { "east us 2", "East US 2" }, - { "eastus2", "East US 2" }, - { "southcentralus", "South Central US" }, - { "south central us", "South Central US" }, - { "australiaeast", "Australia East" }, - { "australia east", "Australia East" }, - { "uksouth", "UK South" }, - { "uk south", "UK South" }, - { "northeurope", "North Europe" }, - { "westeurope", "West Europe" }, - { "brazilsouth", "Brazil South" }, - { "japaneast", "Japan East" }, - { "koreacentral", "Korea Central" }, - { "centraluseuap", "Central US EUAP" }, - { "eastus2euap", "East US 2 EUAP" }, - { "switzerlandnorth", "Switzerland North" }, - { "swedencentral", "Sweden Central" }, - { "qatarcentral", "Qatar Central" }, - { "italynorth", "Italy North" }, - - // Government regions - { "usgovvirginia", "USGov Virginia" }, - { "usgovarizona", "USGov Arizona" }, - { "usdodcentral", "USDoD Central" }, - { "usseceast", "USSec East" }, - { "usnateast", "USNat East" }, - - // China regions - { "chinaeast2", "China East 2" }, - { "chinanorth3", "China North 3" }, - - // Newer regions - { "mexicocentral", "Mexico Central" }, - { "israelcentral", "Israel Central" }, - { "newzealandnorth", "New Zealand North" }, - - // Hyphen-separated variants - { "west-us-3", "West US 3" }, - { "east-us", "East US" }, - { "north-europe", "North Europe" }, - { "south-central-us", "South Central US" }, - - // Underscore-separated variants - { "west_us_3", "West US 3" }, - { "east_us", "East US" }, - { "north_europe", "North Europe" }, - { "south_central_us", "South Central US" }, - - // Mixed separators - { "West-US_3", "West US 3" }, - { "EAST_US", "East US" }, - }; - } - - @Test(groups = "unit", dataProvider = "regionNameVariants") - public void shouldNormalizeRegionNameVariants(String input, String expectedCanonical) { - String result = RegionUtils.getCanonicalRegionName(input); - assertThat(result).isEqualTo(expectedCanonical); - } - - @Test(groups = "unit") - public void shouldNormalizeUnknownRegions() { - // Unknown regions should be returned as-is (customer input preserved) - assertThat(RegionUtils.getCanonicalRegionName("MyCustomRegion")).isEqualTo("MyCustomRegion"); - assertThat(RegionUtils.getCanonicalRegionName("FutureRegion42")).isEqualTo("FutureRegion42"); - } - - @Test(groups = "unit") - public void shouldHandleNullAndEmpty() { - assertThat(RegionUtils.getCanonicalRegionName(null)).isNull(); - assertThat(RegionUtils.getCanonicalRegionName("")).isEqualTo(""); - } - - @Test(groups = "unit") - public void shouldHandleBlankString() { - // Blank strings (only spaces) are returned as-is — nonsensical input, no match expected - assertThat(RegionUtils.getCanonicalRegionName(" ")).isEqualTo(" "); - } - - @Test(groups = "unit") - public void unknownRegionVariantsShouldBeReturnedAsIs() { - // Unknown regions: returned as customer-passed string (no transformation) - assertThat(RegionUtils.getCanonicalRegionName("futureregion99")).isEqualTo("futureregion99"); - assertThat(RegionUtils.getCanonicalRegionName("Future Region 99")).isEqualTo("Future Region 99"); - assertThat(RegionUtils.getCanonicalRegionName("FUTURE REGION 99")).isEqualTo("FUTURE REGION 99"); - } - - // ======================================================================== - // normalizeRegionNames tests - // ======================================================================== - - @Test(groups = "unit") - public void normalizeRegionNames_shouldNormalizeList() { - assertThat(RegionUtils.canonicalizeRegionNames(Arrays.asList("westus3", "east us"))) - .containsExactly("West US 3", "East US"); - } - - @Test(groups = "unit") - public void normalizeRegionNames_shouldHandleNullAndEmpty() { - assertThat(RegionUtils.canonicalizeRegionNames(null)).isEmpty(); - assertThat(RegionUtils.canonicalizeRegionNames(Collections.emptyList())).isEmpty(); - } - - @Test(groups = "unit") - public void normalizeRegionNames_shouldDropNullElements() { - assertThat(RegionUtils.canonicalizeRegionNames(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/PartitionScopedRegionLevelProgress.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionScopedRegionLevelProgress.java index b25bff7463e7..b168cf786eec 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.RegionUtils; +import com.azure.cosmos.implementation.routing.RegionIdRegistry; import com.azure.cosmos.models.PartitionKeyDefinition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -125,7 +125,7 @@ public void tryRecordSessionToken( String normalizedRegionRoutedTo = this.normalizedRegionLookupMap.get(regionRoutedTo); - int regionId = RegionUtils.getRegionId(normalizedRegionRoutedTo); + int regionId = RegionIdRegistry.getRegionId(normalizedRegionRoutedTo); if (regionId != -1) { long localLsn = localLsnByRegion.v.getOrDefault(regionId, Long.MIN_VALUE); @@ -354,7 +354,7 @@ public ISessionToken tryResolveSessionToken( for (Map.Entry localLsnByRegionEntry : localLsnByRegion.v.entrySet()) { int regionId = localLsnByRegionEntry.getKey(); - String normalizedRegionName = RegionUtils.getNormalizedRegionName(regionId); + String normalizedRegionName = RegionIdRegistry.getNormalizedRegionNameForId(regionId); // the regionId to normalizedRegionName does not exist if (normalizedRegionName.equals(StringUtils.EMPTY)) { @@ -400,7 +400,7 @@ public ISessionToken tryResolveSessionToken( // Obtain globalLsn from hub region for (String lesserPreferredRegionPkProbablyRequestedFrom : lesserPreferredRegionsPkProbablyRequestedFrom) { - int regionId = RegionUtils.getRegionId(lesserPreferredRegionPkProbablyRequestedFrom); + int regionId = RegionIdRegistry.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 6746b6e6466d..6de92037a0e5 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.RegionUtils; +import com.azure.cosmos.implementation.routing.RegionIdRegistry; import com.azure.cosmos.implementation.routing.RegionalRoutingContext; import com.azure.cosmos.implementation.spark.OperationContext; import com.azure.cosmos.implementation.spark.OperationContextAndListenerTuple; @@ -833,7 +833,7 @@ private boolean isRegionScopingOfSessionTokensPossible(DatabaseAccount databaseA while (readableLocationsIterator.hasNext()) { DatabaseAccountLocation readableLocation = readableLocationsIterator.next(); - if (RegionUtils.getRegionId(readableLocation.getName()) == -1) { + if (RegionIdRegistry.getRegionId(readableLocation.getName()) == -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 66b577e4dbd8..037e564c01aa 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 @@ -357,7 +357,7 @@ private UnmodifiableList getApplicableRegionRoutingConte normalizedExcludes = new HashSet<>(userConfiguredExcludeRegions.size()); for (String excludeRegion : userConfiguredExcludeRegions) { if (excludeRegion != null) { - normalizedExcludes.add(RegionUtils.getNormalizedRegionName(excludeRegion)); + normalizedExcludes.add(RegionNameNormalizer.normalize(excludeRegion)); } } } @@ -365,7 +365,7 @@ private UnmodifiableList getApplicableRegionRoutingConte for (RegionalRoutingContext endpoint : regionalRoutingContexts) { Utils.ValueHolder regionName = new Utils.ValueHolder<>(); if (Utils.tryGetValue(regionNameByRegionalRoutingContext, endpoint, regionName) - && !normalizedExcludes.contains(RegionUtils.getNormalizedRegionName(regionName.v))) { + && !normalizedExcludes.contains(RegionNameNormalizer.normalize(regionName.v))) { applicableEndpoints.add(endpoint); } } @@ -520,7 +520,7 @@ private UnmodifiableList reevaluate( // unknown regions in any input form (e.g., "plutocentral" vs "Pluto Central") // match consistently. if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) - && !containsNormalizedRegion(userConfiguredExcludeRegions, RegionUtils.getNormalizedRegionName(internalExcludeRegion))) { + && !containsNormalizedRegion(userConfiguredExcludeRegions, RegionNameNormalizer.normalize(internalExcludeRegion))) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -543,7 +543,7 @@ private static boolean containsNormalizedRegion(List userExcludeRegions, return false; } for (String region : userExcludeRegions) { - if (region != null && normalizedTarget.equals(RegionUtils.getNormalizedRegionName(region))) { + if (region != null && normalizedTarget.equals(RegionNameNormalizer.normalize(region))) { return true; } } @@ -608,7 +608,7 @@ public boolean shouldRefreshEndpoints(Utils.ValueHolder canRefreshInBac // mostPreferredLocation came from preferredLocations (normalized form) or // effectivePreferredLocations (server-form). Look up against the parallel // normalized map after normalizing the key to handle both cases. - String normalizedMostPreferredLocation = RegionUtils.getNormalizedRegionName(mostPreferredLocation); + String normalizedMostPreferredLocation = RegionNameNormalizer.normalize(mostPreferredLocation); if (Utils.tryGetValue(currentLocationInfo.availableReadRegionalRoutingContextsByNormalizedRegionName, normalizedMostPreferredLocation, mostPreferredReadEndpointHolder)) { logger.debug("most preferred is [{}], most preferred available is [{}]", @@ -652,7 +652,7 @@ public boolean shouldRefreshEndpoints(Utils.ValueHolder canRefreshInBac return shouldRefresh; } } else if (!Strings.isNullOrEmpty(mostPreferredLocation)) { - String normalizedMostPreferredLocationForWrite = RegionUtils.getNormalizedRegionName(mostPreferredLocation); + String normalizedMostPreferredLocationForWrite = RegionNameNormalizer.normalize(mostPreferredLocation); if (Utils.tryGetValue(currentLocationInfo.availableWriteRegionalRoutingContextsByNormalizedRegionName, normalizedMostPreferredLocationForWrite, mostPreferredWriteEndpointHolder)) { shouldRefresh = ! areEqual(mostPreferredWriteEndpointHolder.v,writeLocationEndpoints.get(0)); @@ -976,7 +976,7 @@ private void addRoutingContexts( // customer preferred/excluded-region routing. Lets unknown regions in // no-space form like "plutocentral" resolve to the same endpoint as the // server-form-lowercased entry above. - String normalizedLocation = RegionUtils.getNormalizedRegionName(gatewayDbAccountLocation.getName()); + String normalizedLocation = RegionNameNormalizer.normalize(gatewayDbAccountLocation.getName()); if (!endpointsByNormalizedLocation.containsKey(normalizedLocation)) { endpointsByNormalizedLocation.put(normalizedLocation, regionalRoutingContext); } @@ -1142,7 +1142,7 @@ public DatabaseAccountLocationsInfo(List preferredLocations, // any input variant — "West US 3", "westus3", "WEST-US-3", or unknown regions in // no-space form like "plutocentral" — resolves consistently. this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream() - .map(RegionUtils::getNormalizedRegionName) + .map(RegionNameNormalizer::normalize) .collect(Collectors.toList())); this.effectivePreferredLocations = new UnmodifiableList<>(Collections.emptyList()); this.availableWriteRegionalRoutingContextsByRegionName diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java index 2591d549738f..e07f9a443e67 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/LocationHelper.java @@ -49,8 +49,8 @@ private static String dataCenterToUriPostfix(String dataCenter) { // DNS is case-insensitive (RFC 4343), so the lowercase change vs. the old space-strip-only // behavior is invisible to resolution. The hyphen/underscore strips are no-ops on real // server-returned region names (e.g., "West US 3") but keep this aligned with the single - // toNormalizedForm used across all lookup paths. - return RegionUtils.getNormalizedRegionName(dataCenter); + // normalization used across all lookup paths. + return RegionNameNormalizer.normalize(dataCenter); } } diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionIdRegistry.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionIdRegistry.java new file mode 100644 index 000000000000..ae7bc1525a10 --- /dev/null +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionIdRegistry.java @@ -0,0 +1,223 @@ +// 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.Map; + +/** + * Authoritative region-ID registry for the Cosmos Java SDK. + *

+ * This is the only place that depends on the compiled-in region table. It is intentionally + * scoped to the operations that fundamentally cannot work without an SDK-side region inventory: + *

    + *
  • {@link #getRegionId(String)} — map a region name to its numeric ID (used by + * {@code PartitionScopedRegionLevelProgress} and {@code RxDocumentClientImpl}).
  • + *
  • {@link #getNormalizedRegionNameForId(int)} — reverse lookup for diagnostics and + * progress-tracking (used by {@code PartitionScopedRegionLevelProgress}).
  • + *
+ *

+ * For ID-free operations — comparing two region-name strings regardless of casing / separators — + * use {@link RegionNameNormalizer} instead. Doing so keeps endpoint resolution decoupled from the + * SDK's compile-time region inventory, so a region the SDK has not yet learned about still routes + * correctly. + *

+ * The static map must stay in sync with the authoritative regionToIdMapping in + * Settings.xml. + */ +public final class RegionIdRegistry { + + /** + * Canonical region name → numeric region ID. + * Subset of all known Azure regions — only those with an assigned region ID. + */ + public static final Map CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS; + + /** Normalized name → region ID (e.g., "westus3" → 77). */ + public static final Map NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS; + + /** Region ID → normalized name (e.g., 77 → "westus3"). */ + public static final Map REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS; + + static { + Map canonicalToId = new HashMap<>(); + canonicalToId.put("East US", 1); + canonicalToId.put("East US 2", 2); + canonicalToId.put("Central US", 3); + canonicalToId.put("North Central US", 4); + canonicalToId.put("South Central US", 5); + canonicalToId.put("West Central US", 6); + canonicalToId.put("West US", 7); + canonicalToId.put("West US 2", 8); + canonicalToId.put("Canada East", 9); + canonicalToId.put("Canada Central", 10); + canonicalToId.put("Brazil South", 11); + canonicalToId.put("North Europe", 12); + canonicalToId.put("West Europe", 13); + canonicalToId.put("France Central", 14); + canonicalToId.put("France South", 15); + canonicalToId.put("UK West", 16); + canonicalToId.put("UK South", 17); + canonicalToId.put("Germany Central", 18); + canonicalToId.put("Germany Northeast", 19); + canonicalToId.put("Germany North", 20); + canonicalToId.put("Germany West Central", 21); + canonicalToId.put("Switzerland North", 22); + canonicalToId.put("Switzerland West", 23); + canonicalToId.put("Southeast Asia", 24); + canonicalToId.put("East Asia", 25); + canonicalToId.put("Australia East", 26); + canonicalToId.put("Australia Southeast", 27); + canonicalToId.put("Australia Central", 28); + canonicalToId.put("Australia Central 2", 29); + canonicalToId.put("China East", 30); + canonicalToId.put("China North", 31); + canonicalToId.put("Central India", 32); + canonicalToId.put("West India", 33); + canonicalToId.put("South India", 34); + canonicalToId.put("Japan East", 35); + canonicalToId.put("Japan West", 36); + canonicalToId.put("Korea Central", 37); + canonicalToId.put("Korea South", 38); + canonicalToId.put("USGov Virginia", 39); + canonicalToId.put("USGov Iowa", 40); + canonicalToId.put("USGov Arizona", 41); + canonicalToId.put("USGov Texas", 42); + canonicalToId.put("USDoD East", 43); + canonicalToId.put("USDoD Central", 44); + canonicalToId.put("USSec East", 45); + canonicalToId.put("USSec West", 46); + canonicalToId.put("South Africa West", 47); + canonicalToId.put("South Africa North", 48); + canonicalToId.put("UAE Central", 49); + canonicalToId.put("UAE North", 50); + canonicalToId.put("Central US EUAP", 51); + canonicalToId.put("East US 2 EUAP", 52); + canonicalToId.put("North Europe 2", 53); + canonicalToId.put("East Europe", 54); + canonicalToId.put("APAC Southeast 2", 55); + canonicalToId.put("UK South 2", 56); + canonicalToId.put("UK North", 57); + canonicalToId.put("East US STG", 58); + canonicalToId.put("South Central US STG", 59); + canonicalToId.put("Norway East", 60); + canonicalToId.put("Norway West", 61); + canonicalToId.put("USGov Wyoming", 62); + canonicalToId.put("USDoD Southwest", 63); + canonicalToId.put("USDoD West Central", 64); + canonicalToId.put("USDoD South Central", 65); + canonicalToId.put("China East 2", 66); + canonicalToId.put("China North 2", 67); + canonicalToId.put("USNat East", 68); + canonicalToId.put("USNat West", 69); + canonicalToId.put("China North 10", 70); + canonicalToId.put("Sweden Central", 71); + canonicalToId.put("Sweden South", 72); + canonicalToId.put("Korea South 2", 73); + canonicalToId.put("Brazil Southeast", 74); + canonicalToId.put("Brazil Northeast", 75); + canonicalToId.put("Chile Central", 76); + canonicalToId.put("West US 3", 77); + canonicalToId.put("Jio India West", 78); + canonicalToId.put("Jio India Central", 79); + canonicalToId.put("Qatar Central", 80); + canonicalToId.put("Israel Central", 81); + canonicalToId.put("Mexico Central", 82); + canonicalToId.put("Spain Central", 83); + canonicalToId.put("Taiwan North", 84); + canonicalToId.put("Singapore Gov", 85); + canonicalToId.put("Poland Central", 86); + canonicalToId.put("Chile North Central", 87); + canonicalToId.put("USSec Central", 88); + canonicalToId.put("Malaysia West", 89); + canonicalToId.put("New Zealand North", 90); + canonicalToId.put("Italy North", 91); + canonicalToId.put("East US SLV", 92); + canonicalToId.put("China North 3", 93); + canonicalToId.put("China East 3", 94); + canonicalToId.put("Austria East", 95); + canonicalToId.put("Taiwan Northwest", 96); + canonicalToId.put("Belgium Central", 97); + canonicalToId.put("Malaysia South", 98); + canonicalToId.put("India South Central", 99); + canonicalToId.put("Indonesia Central", 100); + canonicalToId.put("Finland Central", 101); + canonicalToId.put("Israel Northwest", 102); + canonicalToId.put("Denmark East", 103); + canonicalToId.put("Southeast US", 104); + canonicalToId.put("Ocave", 105); + canonicalToId.put("Arlem", 106); + canonicalToId.put("Bleu France Central", 107); + canonicalToId.put("Bleu France South", 108); + canonicalToId.put("Delos Cloud Germany Central", 109); + canonicalToId.put("Delos Cloud Germany North", 110); + canonicalToId.put("Singapore Central", 111); + canonicalToId.put("Singapore North", 112); + canonicalToId.put("USSec West Central", 113); + canonicalToId.put("South Central US 2", 114); + canonicalToId.put("Southwest US", 115); + canonicalToId.put("East US 3", 116); + canonicalToId.put("Southeast US 3", 117); + canonicalToId.put("USNat North", 118); + canonicalToId.put("Southeast US 5", 119); + canonicalToId.put("Saudi Arabia East", 120); + canonicalToId.put("West Central US FRE", 121); + canonicalToId.put("Northeast US 5", 122); + canonicalToId.put("Southeast Asia 3", 123); + canonicalToId.put("North Europe 3", 124); + + CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(canonicalToId); + + // Derive the normalized-name maps from the canonical table. + Map normalizedToId = new HashMap<>(canonicalToId.size()); + Map idToNormalized = new HashMap<>(canonicalToId.size()); + for (Map.Entry entry : canonicalToId.entrySet()) { + String normalizedName = RegionNameNormalizer.normalize(entry.getKey()); + if (normalizedToId.put(normalizedName, entry.getValue()) != null) { + throw new IllegalStateException("Duplicate normalized region name '" + normalizedName + + "' in CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS"); + } + if (idToNormalized.put(entry.getValue(), normalizedName) != null) { + throw new IllegalStateException("Duplicate region ID " + entry.getValue() + + " in CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS"); + } + } + NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS = Collections.unmodifiableMap(normalizedToId); + REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS = Collections.unmodifiableMap(idToNormalized); + } + + private RegionIdRegistry() { + } + + /** + * Returns the region ID for a region name (any format accepted), or {@code -1} if the + * region is not in the SDK's registry. + *

+ * Fast path: tries the raw key first (zero allocations when input is already normalized). + * Slow path: normalizes via {@link RegionNameNormalizer#normalize(String)} and retries. + */ + public static int getRegionId(String regionName) { + if (StringUtils.isEmpty(regionName)) { + return -1; + } + Integer id = NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.get(regionName); + if (id != null) { + return id; + } + String normalizedName = RegionNameNormalizer.normalize(regionName); + Integer fallback = NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.get(normalizedName); + return fallback != null ? fallback : -1; + } + + /** + * Returns the normalized name for a region ID, or {@code ""} if the ID is not in the + * SDK's registry. + */ + public static String getNormalizedRegionNameForId(int regionId) { + return REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.getOrDefault(regionId, StringUtils.EMPTY); + } +} diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameNormalizer.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameNormalizer.java new file mode 100644 index 000000000000..dcd3338cc2a0 --- /dev/null +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameNormalizer.java @@ -0,0 +1,48 @@ +// 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.Locale; + +/** + * Pure region-name normalization. Lowercase + strip spaces, hyphens, and underscores. + *

+ * Used wherever the SDK needs case- and separator-insensitive equality between two region + * name strings it already has in hand (e.g., comparing customer-supplied exclude regions + * against gateway-supplied region names). The result is suitable for {@link String#equals(Object)} + * comparison and {@code HashSet} membership. + *

+ * Examples: + *

    + *
  • {@code "West US 3"} → {@code "westus3"}
  • + *
  • {@code "EAST-US"} → {@code "eastus"}
  • + *
  • {@code "east_us_2"} → {@code "eastus2"}
  • + *
  • {@code "Future Region"} → {@code "futureregion"} (unknown regions pass through correctly)
  • + *
+ *

+ * This class deliberately does not consult any static region map. Symmetric normalization + * means two strings that the SDK does not know about still compare correctly to each other. + * For ID-aware operations (canonical name lookup, region-ID resolution) see {@link RegionIdRegistry}. + */ +public final class RegionNameNormalizer { + + private RegionNameNormalizer() { + } + + /** + * Returns the normalized form of a region name, or the input itself if it is null or empty. + * Pure string transform; never throws and never allocates a string when the input is empty. + */ + public static String normalize(String regionName) { + if (StringUtils.isEmpty(regionName)) { + return regionName; + } + return regionName.toLowerCase(Locale.ROOT) + .replace(" ", "") + .replace("-", "") + .replace("_", ""); + } +} 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 deleted file mode 100644 index be63ce2b587d..000000000000 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionUtils.java +++ /dev/null @@ -1,358 +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.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -/** - * Single source of truth for Azure region name mappings in the Cosmos Java SDK. - *

- * Provides two capabilities: - *

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

- * Fast path: tries the raw key first (zero allocations for already-normalized input). - * Slow path: normalizes (lowercase + strip spaces) and retries. - * - * @param regionName the region name in any format (canonical, normalized, or raw) - * @return the region ID, or -1 if not found - */ - public static int getRegionId(String regionName) { - if (StringUtils.isEmpty(regionName)) { - return -1; - } - // Fast path: input is already in normalized form (e.g., "westus3") - int id = NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(regionName, -1); - if (id != -1) { - return id; - } - // Slow path: normalize input to lowercase, strip spaces/hyphens/underscores and retry - String normalizedName = toNormalizedForm(regionName); - return NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.getOrDefault(normalizedName, -1); - } - - /** - * Returns the canonical CosmosDB region name for any input variant. - *

- * Converts input to normalized form (lowercase, no spaces), then looks up - * the canonical display name in the static map. - *

    - *
  • Known region: returns canonical form (e.g., "westus3" → "West US 3")
  • - *
  • Unknown region: returns customer-passed string as-is
  • - *
- * - * @param regionName the region name in any format (e.g., "westus3", "WEST US 3", "West US 3") - * @return the canonical region name (e.g., "West US 3"), or the input as-is if unrecognized - */ - public static String getCanonicalRegionName(String regionName) { - if (StringUtils.isEmpty(regionName)) { - return regionName; - } - - // Normalize to lowercase, strip spaces/hyphens/underscores for map lookup - String normalizedName = toNormalizedForm(regionName); - - // Look up canonical display name - String canonicalName = NORMALIZED_TO_CANONICAL.get(normalizedName); - if (canonicalName != null) { - return canonicalName; - } - - // Unknown region — return customer-passed string as-is. - // Downstream consumers apply toLowerCase() or equalsIgnoreCase() as needed. - return regionName; - } - - /** - * Returns the normalized form of a region name: lowercase, no spaces/hyphens/underscores. - *

- * Examples: - *

    - *
  • "West US 3" → "westus3"
  • - *
  • "EAST-US" → "eastus"
  • - *
  • "east_us_2" → "eastus2"
  • - *
  • "Future Region" → "futureregion"
  • - *
- * Used by {@code LocationHelper} for constructing regional endpoint URLs. - * DNS is case-insensitive, so casing doesn't affect resolution. - * - * @param regionName the region name in any format - * @return the normalized form (lowercase, no spaces/hyphens/underscores) - */ - public static String getNormalizedRegionName(String regionName) { - if (StringUtils.isEmpty(regionName)) { - return regionName; - } - return toNormalizedForm(regionName); - } - - /** - * Converts a list of region names to their canonical CosmosDB form. - *

- * Known regions are mapped to canonical names (e.g., "westus3" → "West US 3"). - * Unknown regions are passed through as-is. Null elements are dropped. - * - * @param regionNames the list of region names in any format - * @return a new list with each region in canonical form - */ - public static List canonicalizeRegionNames(List regionNames) { - if (regionNames == null || regionNames.isEmpty()) { - return Collections.emptyList(); - } - List canonicalized = new ArrayList<>(regionNames.size()); - for (String region : regionNames) { - if (region != null) { - canonicalized.add(getCanonicalRegionName(region)); - } - } - return canonicalized; - } - - /** - * Checks whether a list of region names contains the target region, - * using canonical normalization + case-insensitive comparison. - *

- * Both the list elements and the target are first canonicalized via - * {@link #getCanonicalRegionName(String)}, then compared with - * {@code equalsIgnoreCase}. This handles all format variants: - * "westus3", "West US 3", "WEST US 3" all match each other. - * - * @param regions the list of region names to search (any format) - * @param target the target region name to find (any format) - * @return true if any region in the list matches the target after canonicalization - */ - public static boolean containsRegionIgnoreCase(List regions, String target) { - if (regions == null || regions.isEmpty()) { - return false; - } - String canonicalTarget = getCanonicalRegionName(target); - for (String region : regions) { - if (region != null && getCanonicalRegionName(region).equalsIgnoreCase(canonicalTarget)) { - return true; - } - } - return false; - } -} From 90a6e5833be0875c32c8aa21f924f22ef4ff46b0 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Fri, 29 May 2026 20:25:59 -0400 Subject: [PATCH 49/58] Honor user-exclude in global-fallback branch of getApplicableRegionRoutingContexts Mirrors the existing guard in the sibling else-branch so that when PPCB drives the path into the global-fallback branch, regions on the user exclude list are not re-added as retry targets. Addresses xinlian12's review comment at LocationCache.java:507. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cosmos/implementation/routing/LocationCache.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 037e564c01aa..36894b5682d1 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 @@ -504,7 +504,11 @@ private UnmodifiableList reevaluate( // is keyed by server-form-lowercased, so look it up directly. if (Utils.tryGetValue(regionalRoutingContextsByRegionName, internalExcludeRegion, regionalRoutingContextValueHolder)) { - if (!regionalRoutingContextValueHolder.v.equals(hubRoutingContext)) { + // Also honor the user-exclude list here so the global-fallback path does + // not re-add a region the user explicitly excluded. Mirrors the guard in + // the sibling else-branch below. + if (!regionalRoutingContextValueHolder.v.equals(hubRoutingContext) + && !containsNormalizedRegion(userConfiguredExcludeRegions, RegionNameNormalizer.normalize(internalExcludeRegion))) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } From 756f5005a3aca2ad63fb5d5861b4437c23d7d37d Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Fri, 29 May 2026 21:30:17 -0400 Subject: [PATCH 50/58] Migrate residual ad-hoc region normalizers to RegionNameNormalizer Replaces inline toLowerCase + replace patterns in PartitionScopedRegionLevelProgress, PartitionKeyBasedBloomFilter, and RegionScopedSessionContainer with RegionNameNormalizer.normalize so all region-name normalization in azure-cosmos uses a single helper. Addresses xinlian12's PartitionScopedRegionLevelProgress comment (non-blocking) and removes the now-unused java.util.Locale imports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cosmos/implementation/PartitionKeyBasedBloomFilter.java | 4 ++-- .../implementation/PartitionScopedRegionLevelProgress.java | 4 ++-- .../cosmos/implementation/RegionScopedSessionContainer.java | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionKeyBasedBloomFilter.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionKeyBasedBloomFilter.java index 001257dc3951..9efd93c81b30 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionKeyBasedBloomFilter.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/PartitionKeyBasedBloomFilter.java @@ -8,11 +8,11 @@ import com.azure.cosmos.implementation.guava25.hash.PrimitiveSink; import com.azure.cosmos.implementation.routing.PartitionKeyInternal; import com.azure.cosmos.implementation.routing.PartitionKeyInternalHelper; +import com.azure.cosmos.implementation.routing.RegionNameNormalizer; import com.azure.cosmos.models.PartitionKeyDefinition; import java.nio.charset.StandardCharsets; import java.util.HashSet; -import java.util.Locale; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicLong; @@ -81,7 +81,7 @@ public void tryRecordPartitionKey( String effectivePartitionKeyString = request.getEffectivePartitionKey() != null ? request.getEffectivePartitionKey() : PartitionKeyInternalHelper .getEffectivePartitionKeyString(partitionKeyInternal, partitionKeyDefinition); - String normalizedRegionRoutedTo = regionRoutedTo.toLowerCase(Locale.ROOT).replace(" ", "");; + String normalizedRegionRoutedTo = RegionNameNormalizer.normalize(regionRoutedTo); // 1. record region information for EPK hash only if this EPK was resolved // to a different preferred region than the first preferred region 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 b168cf786eec..41d6a45acf86 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 @@ -9,12 +9,12 @@ import com.azure.cosmos.implementation.apachecommons.lang.StringUtils; import com.azure.cosmos.implementation.routing.PartitionKeyInternal; import com.azure.cosmos.implementation.routing.RegionIdRegistry; +import com.azure.cosmos.implementation.routing.RegionNameNormalizer; import com.azure.cosmos.models.PartitionKeyDefinition; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashSet; -import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -121,7 +121,7 @@ public void tryRecordSessionToken( return regionLevelProgressAsVal; } - this.normalizedRegionLookupMap.computeIfAbsent(regionRoutedTo, (regionRoutedToAsVal) -> regionRoutedToAsVal.toLowerCase(Locale.ROOT).trim().replace(" ", "")); + this.normalizedRegionLookupMap.computeIfAbsent(regionRoutedTo, RegionNameNormalizer::normalize); String normalizedRegionRoutedTo = this.normalizedRegionLookupMap.get(regionRoutedTo); diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RegionScopedSessionContainer.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RegionScopedSessionContainer.java index 12f766f8f43e..0c79ef9f3c4f 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RegionScopedSessionContainer.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RegionScopedSessionContainer.java @@ -8,6 +8,7 @@ import com.azure.cosmos.implementation.apachecommons.lang.tuple.Pair; import com.azure.cosmos.implementation.routing.PartitionKeyInternal; import com.azure.cosmos.implementation.routing.PartitionKeyInternalHelper; +import com.azure.cosmos.implementation.routing.RegionNameNormalizer; import com.azure.cosmos.implementation.routing.RegionalRoutingContext; import com.azure.cosmos.models.PartitionKeyDefinition; import org.slf4j.Logger; @@ -16,7 +17,6 @@ import java.net.URI; import java.util.Iterator; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -461,7 +461,7 @@ private String extractFirstEffectivePreferredReadableRegion() { checkNotNull(regionNamesForRead, "regionNamesForRead cannot be null!"); if (!regionNamesForRead.isEmpty()) { - return regionNamesForRead.get(0).toLowerCase(Locale.ROOT).trim().replace(" ", ""); + return RegionNameNormalizer.normalize(regionNamesForRead.get(0)); } throw new IllegalStateException("regionNamesForRead cannot be empty!"); From b5f79f7bee0ac63ef8ac58e2505b8661d599797a Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Sat, 30 May 2026 08:07:23 -0400 Subject: [PATCH 51/58] Address xinlian12 review comments 1. RegionIdRegistryTests: use RegionNameNormalizer.normalize() for the canonical- to-normalized derivation inside regionIdToRegionNameConsistency instead of the ad-hoc lowercase + replace pattern. The production map is built with the normalizer (which also strips hyphens and underscores), so the test was validating map consistency with a divergent normalization function and silently providing false confidence for any future region whose canonical name contains a hyphen or underscore. 2. RegionIdRegistryTests: add getRegionIdAcceptsAllVariants pinning the public getRegionId(String) fast-path / slow-path / null / empty / unknown contract and the empty-string sentinel of getNormalizedRegionNameForId(int). Covers canonical, normalized, hyphenated, underscored and uppercase input variants. 3. LocationCache: document the mixed-form contract on the preferredLocations / effectivePreferredLocations fields. preferredLocations holds normalized-form strings whereas effectivePreferredLocations mirrors availableReadLocations (server-form, spaces preserved); callers must normalize before any lookup against the normalized routing maps. Field-level javadoc prevents a future developer from adding a direct lookup that would silently miss. 4. ApplicableRegionEvaluatorTest: rewrite the PPCB reevaluate regression test so it actually exercises containsNormalizedRegion. The previous scenario excluded 1 of 3 regions, which left 2 endpoints applicable and hit the early-return at LocationCache line 440 before reaching the inner code under test (the assertion passed because the main user-exclude loop had already removed East US, not because containsNormalizedRegion was correct). The new scenario excludes 2 of 3 regions so only Central US remains applicable; reevaluate then iterates internalExcludeRegions, calls containsNormalizedRegion to check whether East US is also user-excluded, and the assertion fails iff the normalized check is removed. Verified: 2409 unit tests pass, 0 failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../implementation/RegionIdRegistryTests.java | 42 ++++++++++++++++++- .../ApplicableRegionEvaluatorTest.java | 26 ++++++++---- .../implementation/routing/LocationCache.java | 24 ++++++++--- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionIdRegistryTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionIdRegistryTests.java index fbdb8aa5ab73..6826793db44d 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionIdRegistryTests.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionIdRegistryTests.java @@ -4,11 +4,11 @@ package com.azure.cosmos.implementation; import com.azure.cosmos.implementation.routing.RegionIdRegistry; +import com.azure.cosmos.implementation.routing.RegionNameNormalizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.testng.annotations.Test; -import java.util.Locale; import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; @@ -21,7 +21,9 @@ public class RegionIdRegistryTests { public void regionIdToRegionNameConsistency() { for (Map.Entry sourceEntry : RegionIdRegistry.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { - String normalizedRegionNameFromSource = sourceEntry.getKey().toLowerCase(Locale.ROOT).replace(" ", "").trim(); + // Use the same normalization that builds NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS, + // so the test proves the actual derivation path rather than an approximation of it. + String normalizedRegionNameFromSource = RegionNameNormalizer.normalize(sourceEntry.getKey()); Integer regionIdFromSource = sourceEntry.getValue(); logger.info("Testing for region : {} and region id : {}", normalizedRegionNameFromSource, regionIdFromSource); @@ -39,4 +41,40 @@ public void regionIdToRegionNameConsistency() { assertThat(RegionIdRegistry.REGION_ID_TO_NORMALIZED_REGION_NAME_MAPPINGS.get(regionIdFromSource)).isEqualTo(normalizedRegionNameFromSource); } } + + /** + * Pins the public {@link RegionIdRegistry#getRegionId(String)} fast-path / slow-path / null / + * empty / unknown contract, plus the empty-string sentinel of + * {@link RegionIdRegistry#getNormalizedRegionNameForId(int)}. Guards against silent + * regressions in the canonical / normalized / hyphen / underscore acceptance surface. + */ + @Test(groups = {"unit"}) + public void getRegionIdAcceptsAllVariants() { + int eastUsId = RegionIdRegistry.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS.get("East US"); + int westUs3Id = RegionIdRegistry.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS.get("West US 3"); + + // Fast path - input is already in normalized form. + assertThat(RegionIdRegistry.getRegionId("eastus")).isEqualTo(eastUsId); + + // Slow path - canonical form, requires normalization step. + assertThat(RegionIdRegistry.getRegionId("East US")).isEqualTo(eastUsId); + assertThat(RegionIdRegistry.getRegionId("EAST US")).isEqualTo(eastUsId); + assertThat(RegionIdRegistry.getRegionId("West US 3")).isEqualTo(westUs3Id); + + // Separator variants (hyphen and underscore) - exercise the strip-[-_] path. + assertThat(RegionIdRegistry.getRegionId("east-us")).isEqualTo(eastUsId); + assertThat(RegionIdRegistry.getRegionId("east_us")).isEqualTo(eastUsId); + assertThat(RegionIdRegistry.getRegionId("west-us-3")).isEqualTo(westUs3Id); + assertThat(RegionIdRegistry.getRegionId("west_us_3")).isEqualTo(westUs3Id); + + // Edge cases - null, empty, unknown all return -1. + assertThat(RegionIdRegistry.getRegionId(null)).isEqualTo(-1); + assertThat(RegionIdRegistry.getRegionId("")).isEqualTo(-1); + assertThat(RegionIdRegistry.getRegionId("Pluto Central")).isEqualTo(-1); + + // Reverse lookup - known ID returns normalized form, unknown ID returns empty string. + assertThat(RegionIdRegistry.getNormalizedRegionNameForId(eastUsId)).isEqualTo("eastus"); + assertThat(RegionIdRegistry.getNormalizedRegionNameForId(westUs3Id)).isEqualTo("westus3"); + assertThat(RegionIdRegistry.getNormalizedRegionNameForId(Integer.MAX_VALUE)).isEqualTo(""); + } } diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java index e0e9b407bc99..89e28eb8d0ae 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java @@ -2425,11 +2425,17 @@ public void reevaluate_nonCanonicalUserExclude_shouldNotReAddPpcbExcludedRegion( locationCache.onDatabaseAccountRead(databaseAccount); try { - // Create request: user excludes "eastus" (non-canonical, no spaces) - // PPCB marks "east us" (server-returned, lowercased with spaces) as unavailable + // Reaching containsNormalizedRegion requires the reevaluate path to enter its + // PPCB-internal-exclude inner loop, which only happens when + // applicableRegionalRoutingContexts.size() < 2 (early-return at LocationCache:440). + // Exclude 2 of 3 regions so only Central US remains applicable; then PPCB excludes + // "east us" - reevaluate iterates internalExcludeRegions and consults + // containsNormalizedRegion(["eastus","westus"], "east us"). Pre-fix string-equal + // check ("east us".equals("eastus") -> false) would silently re-add East US to the + // retry list; the normalized check correctly recognises the match and blocks it. RxDocumentServiceRequest request = RxDocumentServiceRequest.create( mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); - request.requestContext.setExcludeRegions(Arrays.asList("eastus")); + request.requestContext.setExcludeRegions(Arrays.asList("eastus", "westus")); request.requestContext.setUnavailableRegionsForPerPartitionCircuitBreaker(Arrays.asList("east us")); request.requestContext.setCrossRegionAvailabilityContext( @@ -2448,19 +2454,21 @@ public void reevaluate_nonCanonicalUserExclude_shouldNotReAddPpcbExcludedRegion( List applicableEndpoints = globalEndpointManager.getApplicableReadRegionalRoutingContexts(request); - // "East US" / "eastus" / "east us" should all be excluded — the reevaluate path - // should NOT re-add "east us" because containsRegionIgnoreCase recognizes - // that "eastus" (user exclude) matches "east us" (PPCB internal exclude). + // East US is in both user-exclude and PPCB-internal-exclude lists. Without the + // containsNormalizedRegion fix the reevaluate loop would silently re-add East US; + // with the fix the only applicable endpoint is Central US. for (RegionalRoutingContext endpoint : applicableEndpoints) { Assertions.assertThat(endpoint.getGatewayRegionalEndpoint()) .as("East US endpoint should not be re-added by reevaluate when user excluded 'eastus' and PPCB excluded 'east us'") .isNotEqualTo(TestAccountEastUsEndpoint); + Assertions.assertThat(endpoint.getGatewayRegionalEndpoint()) + .as("West US endpoint should remain excluded by the user-exclude main loop") + .isNotEqualTo(TestAccountWestUsEndpoint); } - // Should route to West US or Central US only - Assertions.assertThat(applicableEndpoints.size()).isGreaterThanOrEqualTo(1); + Assertions.assertThat(applicableEndpoints).hasSize(1); Assertions.assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()) - .isIn(TestAccountWestUsEndpoint, TestAccountCentralUsEndpoint); + .isEqualTo(TestAccountCentralUsEndpoint); } finally { globalEndpointManager.close(); } 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 36894b5682d1..450a74127226 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 @@ -1119,12 +1119,24 @@ private static boolean isExcludedRegionsSupplierConfigured(Supplier writeRegionalRoutingContexts; private UnmodifiableList readRegionalRoutingContexts; - private UnmodifiableList preferredLocations; - private UnmodifiableList effectivePreferredLocations; - // lower-case region - private UnmodifiableList availableWriteLocations; - // lower-case region - private UnmodifiableList availableReadLocations; + /** + * Customer-supplied preferred regions, stored in normalized form + * (see {@link RegionNameNormalizer}). Lookups target the parallel + * {@code availableXxxRegionalRoutingContextsByNormalizedRegionName} maps. + */ + private UnmodifiableList preferredLocations; + /** + * Fallback ordering when {@link #preferredLocations} is empty. Stored in + * server-form (lowercased, spaces preserved, e.g. {@code "west us 3"}), + * mirroring {@link #availableReadLocations}. Callers must normalize before any + * lookup against the normalized routing maps - see {@link #shouldRefreshEndpoints} + * and {@link #getPreferredAvailableRoutingContexts} for the existing call sites. + */ + private UnmodifiableList effectivePreferredLocations; + // lower-case region + private UnmodifiableList availableWriteLocations; + // lower-case region + private UnmodifiableList availableReadLocations; private UnmodifiableMap availableWriteRegionalRoutingContextsByRegionName; private UnmodifiableMap availableReadRegionalRoutingContextsByRegionName; // Parallel maps keyed by normalized region name (lowercase, no spaces/separators). Used From 9551b017b271bcd752c211724caece1b14264acf Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Sat, 30 May 2026 09:06:08 -0400 Subject: [PATCH 52/58] Cache raw -> normalized exclude region on the per-request hot path getApplicableRegionRoutingContexts and containsNormalizedRegion both call RegionNameNormalizer.normalize on every user-supplied exclude region on every request. Customers in the common case configure a small fixed set of exclude regions and pass the same strings on every call, so repeating the 4-allocation toLowerCase + 3x String.replace work per request is wasted. Add a per-LocationCache ConcurrentHashMap keyed by the raw customer-supplied string, valued by the normalized form. computeIfAbsent populates on first use; subsequent calls are a single hash lookup. Bounded by EXCLUDE_REGION_CACHE_MAX_SIZE (256). Beyond the cap we fall back to direct normalization so a customer that supplies a different exclude region string per request cannot drive unbounded growth. 256 entries leaves abundant headroom over realistic 1-5-entry workloads while keeping worst-case memory at roughly 10 KB. containsNormalizedRegion is no longer static so it can share the same cache. Verified: 2409 azure-cosmos unit tests pass, 0 failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../implementation/routing/LocationCache.java | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) 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 450a74127226..7051863c2c0c 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 @@ -53,8 +53,24 @@ public class LocationCache { private final Object lockObject; private final Duration unavailableLocationsExpirationTime; private final ConcurrentHashMap locationUnavailabilityInfoByEndpoint; + /** + * Caches {@code rawUserSuppliedExcludeRegion -> normalizedForm} so the per-request + * exclude-region hot path skips repeated {@link RegionNameNormalizer#normalize(String)} + * work for customers that pass the same exclude region strings on every request (the + * common case). Bounded by {@link #EXCLUDE_REGION_CACHE_MAX_SIZE} so a customer that + * supplies a different string per request cannot drive unbounded memory growth - + * beyond the cap we fall back to direct normalization (still correct, just no caching). + */ + private final ConcurrentHashMap rawToNormalizedExcludeRegionCache; private final ConnectionPolicy connectionPolicy; + /** + * Soft cap on {@link #rawToNormalizedExcludeRegionCache}. Realistic customers configure + * a small fixed set of exclude regions (1-5), so 256 leaves abundant headroom while + * keeping worst-case memory at ~10 KB. + */ + private static final int EXCLUDE_REGION_CACHE_MAX_SIZE = 256; + private DatabaseAccountLocationsInfo locationInfo; private Instant lastCacheUpdateTimestamp; @@ -78,6 +94,7 @@ public LocationCache( this.lockObject = new Object(); this.locationUnavailabilityInfoByEndpoint = new ConcurrentHashMap<>(); + this.rawToNormalizedExcludeRegionCache = new ConcurrentHashMap<>(); this.lastCacheUpdateTimestamp = Instant.MIN; this.enableMultipleWriteLocations = false; @@ -357,7 +374,7 @@ private UnmodifiableList getApplicableRegionRoutingConte normalizedExcludes = new HashSet<>(userConfiguredExcludeRegions.size()); for (String excludeRegion : userConfiguredExcludeRegions) { if (excludeRegion != null) { - normalizedExcludes.add(RegionNameNormalizer.normalize(excludeRegion)); + normalizedExcludes.add(this.normalizeExcludeRegion(excludeRegion)); } } } @@ -541,19 +558,37 @@ private UnmodifiableList reevaluate( * each user-supplied entry. Uses the same normalization (lowercase + strip spaces/hyphens/underscores) * that {@link #getApplicableRegionRoutingContexts} uses for the user-exclude check, so that unknown * regions in any input form (e.g., "westus3" vs "West US 3") match consistently. + * Normalization of each user-supplied entry goes through {@link #normalizeExcludeRegion(String)} + * so the per-client cache is reused. */ - private static boolean containsNormalizedRegion(List userExcludeRegions, String normalizedTarget) { + private boolean containsNormalizedRegion(List userExcludeRegions, String normalizedTarget) { if (userExcludeRegions == null || userExcludeRegions.isEmpty()) { return false; } for (String region : userExcludeRegions) { - if (region != null && normalizedTarget.equals(RegionNameNormalizer.normalize(region))) { + if (region != null && normalizedTarget.equals(this.normalizeExcludeRegion(region))) { return true; } } return false; } + /** + * Fast-path normalization for customer-supplied exclude region strings. Customers in the + * common case pass the same exclude regions on every request, so caching + * {@code raw -> normalized} avoids repeated allocations from the per-request hot path. + * Falls back to direct {@link RegionNameNormalizer#normalize(String)} once the cache + * reaches {@link #EXCLUDE_REGION_CACHE_MAX_SIZE} so customers that supply distinct + * strings per request cannot drive unbounded memory growth. + */ + private String normalizeExcludeRegion(String rawExcludeRegion) { + if (this.rawToNormalizedExcludeRegionCache.size() < EXCLUDE_REGION_CACHE_MAX_SIZE) { + return this.rawToNormalizedExcludeRegionCache + .computeIfAbsent(rawExcludeRegion, RegionNameNormalizer::normalize); + } + return RegionNameNormalizer.normalize(rawExcludeRegion); + } + private boolean isExcludeRegionsConfigured(List excludedRegionsOnRequest, List excludedRegionsOnClient) { boolean isExcludedRegionsConfiguredOnRequest = !(excludedRegionsOnRequest == null || excludedRegionsOnRequest.isEmpty()); boolean isExcludedRegionsConfiguredOnClient = !(excludedRegionsOnClient == null || excludedRegionsOnClient.isEmpty()); From b240f22e198f4db5ec20ca9e6af7bcb9e7bf2066 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Sat, 30 May 2026 09:30:10 -0400 Subject: [PATCH 53/58] Add unit tests for exclude-region normalization cache Five tests in LocationCacheTest using reflection to inspect rawToNormalizedExcludeRegionCache occupancy: 1. excludeRegionCache_populatesOnFirstCall - first request with an exclude region populates exactly one entry keyed by the raw customer string. 2. excludeRegionCache_reusesCachedEntryOnRepeatedCall - 50 repeated requests with the same two exclude regions result in cache size 2, not 100. 3. excludeRegionCache_distinctStringsGetDistinctEntries - "West US 3", "WEST US 3", "westus3" and "west-us-3" all normalize to "westus3" but each raw input gets its own cache slot (cache size 4, all values identical). 4. excludeRegionCache_haltsAtCapAndFallsBackToDirectNormalize - reflects EXCLUDE_REGION_CACHE_MAX_SIZE, fills the cache to the cap with synthetic unique strings, then verifies the 257th unique input does NOT grow the cache and that routing still excludes the right region (i.e. the fallback direct-normalize path is correct). 5. excludeRegionCache_skipsNullExcludeEntries - mixed list containing nulls must not NPE and must not insert a null key. Verified: 2414 unit tests pass, 0 failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../routing/LocationCacheTest.java | 132 ++++++++++++++++++ 1 file changed, 132 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 4b8b26d005b9..d5d25cbcdfbb 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 @@ -1222,4 +1222,136 @@ public void unknownRegion_excludeWithDifferentCasingShouldWork() { .collect(Collectors.toList())) .doesNotContain(PlutoCentralEndpoint); } + // ======================================================================== + // Exclude-region normalization cache tests — exercises the per-LocationCache + // ConcurrentHashMap fast path used by + // getApplicableRegionRoutingContexts on the per-request hot path. Asserts on + // cache occupancy via reflection to prove that: + // - repeated invocations with the same exclude string reuse the cached entry + // - distinct strings each get their own entry + // - growth halts at the documented cap and the fallback still normalizes correctly + // ======================================================================== + + @SuppressWarnings("unchecked") + private static java.util.concurrent.ConcurrentHashMap getExcludeRegionCache(LocationCache locationCache) { + try { + java.lang.reflect.Field field = LocationCache.class.getDeclaredField("rawToNormalizedExcludeRegionCache"); + field.setAccessible(true); + return (java.util.concurrent.ConcurrentHashMap) field.get(locationCache); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Could not read rawToNormalizedExcludeRegionCache via reflection", e); + } + } + + private static int getExcludeRegionCacheCap() { + try { + java.lang.reflect.Field field = LocationCache.class.getDeclaredField("EXCLUDE_REGION_CACHE_MAX_SIZE"); + field.setAccessible(true); + return (int) field.get(null); + } catch (ReflectiveOperationException e) { + throw new AssertionError("Could not read EXCLUDE_REGION_CACHE_MAX_SIZE via reflection", e); + } + } + + private static List requestWithExcludeRegions(LocationCache cache, List excludeRegions) { + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + request.requestContext.setExcludeRegions(excludeRegions); + return cache.getApplicableReadRegionRoutingContexts(request); + } + + @Test(groups = "unit") + public void excludeRegionCache_populatesOnFirstCall() { + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + java.util.concurrent.ConcurrentHashMap cache = getExcludeRegionCache(locationCache); + assertThat(cache).isEmpty(); + + requestWithExcludeRegions(locationCache, Arrays.asList("West US 3")); + + assertThat(cache).hasSize(1); + assertThat(cache.get("West US 3")).isEqualTo("westus3"); + } + + @Test(groups = "unit") + public void excludeRegionCache_reusesCachedEntryOnRepeatedCall() { + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + java.util.concurrent.ConcurrentHashMap cache = getExcludeRegionCache(locationCache); + + for (int i = 0; i < 50; i++) { + requestWithExcludeRegions(locationCache, Arrays.asList("West US 3", "East US")); + } + + // Cache size should equal the number of distinct customer-supplied strings, not the + // number of normalize calls. + assertThat(cache).hasSize(2); + assertThat(cache).containsEntry("West US 3", "westus3"); + assertThat(cache).containsEntry("East US", "eastus"); + } + + @Test(groups = "unit") + public void excludeRegionCache_distinctStringsGetDistinctEntries() { + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + java.util.concurrent.ConcurrentHashMap cache = getExcludeRegionCache(locationCache); + + // Same normalized result, different raw inputs - each raw input gets its own entry. + requestWithExcludeRegions(locationCache, Arrays.asList("West US 3")); + requestWithExcludeRegions(locationCache, Arrays.asList("WEST US 3")); + requestWithExcludeRegions(locationCache, Arrays.asList("westus3")); + requestWithExcludeRegions(locationCache, Arrays.asList("west-us-3")); + + assertThat(cache).hasSize(4); + assertThat(cache.values()).containsOnly("westus3"); + } + + @Test(groups = "unit") + public void excludeRegionCache_haltsAtCapAndFallsBackToDirectNormalize() { + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + java.util.concurrent.ConcurrentHashMap cache = getExcludeRegionCache(locationCache); + int cap = getExcludeRegionCacheCap(); + + // Fill the cache exactly to the cap with synthetic unique exclude-region strings. + // None of these match real account regions, so routing is unaffected - we're just + // exercising the cache-population path. + for (int i = 0; i < cap; i++) { + requestWithExcludeRegions(locationCache, Arrays.asList("synthetic-region-" + i)); + } + assertThat(cache).hasSize(cap); + + // Push past the cap with a region that DOES match (East US in no-space form). + // Cache must NOT grow, AND routing must still correctly exclude East US (fallback + // to direct normalize must produce the same answer the cache would have). + List applicable = requestWithExcludeRegions( + locationCache, Arrays.asList("eastus")); + assertThat(cache).hasSize(cap); + assertThat(applicable.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(EastUSEndpoint); + } + + @Test(groups = "unit") + public void excludeRegionCache_skipsNullExcludeEntries() { + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + java.util.concurrent.ConcurrentHashMap cache = getExcludeRegionCache(locationCache); + + List withNull = new ArrayList<>(); + withNull.add(null); + withNull.add("eastus"); + withNull.add(null); + + // Must not NPE and must not insert a null key into the cache. + List applicable = requestWithExcludeRegions(locationCache, withNull); + + assertThat(cache).hasSize(1).containsKey("eastus"); + assertThat(cache).doesNotContainKey(null); + assertThat(applicable.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(EastUSEndpoint); + } } From ef2b81b32ea0a2b661e332c8e139cde0220d333a Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Sun, 31 May 2026 09:24:12 -0400 Subject: [PATCH 54/58] Serialize Cosmos Build stage to dodge scala-maven-plugin 4.8.1 race The Cosmos CI Build stage was failing on main and on this PR with: Failed to execute goal net.alchim31.maven:scala-maven-plugin:4.8.1:compile on project azure-cosmos-spark_3-3_2-12: wrap: java.lang.ClassNotFoundException: xsbt.CompilerInterface Root cause: scala-maven-plugin 4.8.1 has a documented classloader race on the Zinc compiler bridge when two Spark modules compile in parallel. Maven caches one plugin classloader instance shared across threads; when both threads concurrently load the bridge JAR via the plugin's reflective loader, one observes a partial init state. The race is fixed in 4.9.x via internal synchronization around the bridge classloader. A version bump to 4.9.10 is blocked because the Azure SDK proxy feed returns 401 Unauthorized for every 4.9.x version - they have not been pre-cached into the feed and bumping would break CI even worse. Bumping external plugin versions through the feed is a separate, managed process. The minimal correct fix is to serialize the build by dropping BuildParallelization from 2 to 1 for the Build stage only. Locally verified: -T 1 builds the same module set in 11:24 min with no race. Test stages (lines 95, 185) keep BuildParallelization: 2 since they do not hit the scala compile path the same way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/stages/cosmos-sdk-client.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/eng/pipelines/templates/stages/cosmos-sdk-client.yml b/eng/pipelines/templates/stages/cosmos-sdk-client.yml index e93a86154e23..4fbb174dfc13 100644 --- a/eng/pipelines/templates/stages/cosmos-sdk-client.yml +++ b/eng/pipelines/templates/stages/cosmos-sdk-client.yml @@ -61,7 +61,12 @@ extends: MatrixReplace: - AZURE_TEST.*=.*/ - .*Version=1.2(1|5)/1.17 - BuildParallelization: 2 + # NOTE: keep BuildParallelization at 1 — scala-maven-plugin 4.8.1 has a known + # classloader race on the Zinc compiler bridge when two Spark modules compile + # concurrently (manifests as `ClassNotFoundException: xsbt.CompilerInterface`). + # The race is fixed in 4.9.x but the SDK proxy feed does not have 4.9.x cached. + # Raise back to 2 once the plugin is bumped. + BuildParallelization: 1 BuildOptions: '-Dshade.skip=true -Dmaven.antrun.skip=true' TestOptions: '-Punit' From 1e79bab952b34062bf37b90c81cd80783501da71 Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Sun, 31 May 2026 10:47:52 -0400 Subject: [PATCH 55/58] Revert "Serialize Cosmos Build stage to dodge scala-maven-plugin 4.8.1 race" This reverts commit ef2b81b32ea0a2b661e332c8e139cde0220d333a. --- eng/pipelines/templates/stages/cosmos-sdk-client.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/eng/pipelines/templates/stages/cosmos-sdk-client.yml b/eng/pipelines/templates/stages/cosmos-sdk-client.yml index 4fbb174dfc13..e93a86154e23 100644 --- a/eng/pipelines/templates/stages/cosmos-sdk-client.yml +++ b/eng/pipelines/templates/stages/cosmos-sdk-client.yml @@ -61,12 +61,7 @@ extends: MatrixReplace: - AZURE_TEST.*=.*/ - .*Version=1.2(1|5)/1.17 - # NOTE: keep BuildParallelization at 1 — scala-maven-plugin 4.8.1 has a known - # classloader race on the Zinc compiler bridge when two Spark modules compile - # concurrently (manifests as `ClassNotFoundException: xsbt.CompilerInterface`). - # The race is fixed in 4.9.x but the SDK proxy feed does not have 4.9.x cached. - # Raise back to 2 once the plugin is bumped. - BuildParallelization: 1 + BuildParallelization: 2 BuildOptions: '-Dshade.skip=true -Dmaven.antrun.skip=true' TestOptions: '-Punit' From 26c99f4999fd0dc9419a058e4a78b357a9d697ab Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Sun, 31 May 2026 12:37:32 -0400 Subject: [PATCH 56/58] Thread pre-normalized exclude Set through reevaluate; drop containsNormalizedRegion Addresses xinlian12 review comment r3327714916. getApplicableRegionRoutingContextsCore already builds a HashSet normalizedExcludes upfront (one normalize per unique user-supplied entry). Previously that Set was used only for the outer endpoint loop, while reevaluate received the raw List and walked it inside containsNormalizedRegion - re-doing the same normalization work and duplicating the data flow. Refactor: - reevaluate now takes Set normalizedExcludes instead of the raw list, threading the already-normalized data through to the inner PPCB loops at lines 528 and 544. - Membership checks become O(1) Set.contains lookups against the pre-built Set, dropping the O(n) walk per call. - containsNormalizedRegion deleted (10 lines removed). - Updated test comments in ApplicableRegionEvaluatorTest to reference the normalized-Set membership check instead of the removed helper. Net diff: +16/-35. All 962 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicableRegionEvaluatorTest.java | 14 +++---- .../implementation/routing/LocationCache.java | 37 +++++-------------- 2 files changed, 16 insertions(+), 35 deletions(-) diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java index 89e28eb8d0ae..a7775fd8502c 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/ApplicableRegionEvaluatorTest.java @@ -2425,14 +2425,14 @@ public void reevaluate_nonCanonicalUserExclude_shouldNotReAddPpcbExcludedRegion( locationCache.onDatabaseAccountRead(databaseAccount); try { - // Reaching containsNormalizedRegion requires the reevaluate path to enter its - // PPCB-internal-exclude inner loop, which only happens when + // Reaching the user-exclude membership guard inside reevaluate requires that path to + // enter its PPCB-internal-exclude inner loop, which only happens when // applicableRegionalRoutingContexts.size() < 2 (early-return at LocationCache:440). // Exclude 2 of 3 regions so only Central US remains applicable; then PPCB excludes - // "east us" - reevaluate iterates internalExcludeRegions and consults - // containsNormalizedRegion(["eastus","westus"], "east us"). Pre-fix string-equal - // check ("east us".equals("eastus") -> false) would silently re-add East US to the - // retry list; the normalized check correctly recognises the match and blocks it. + // "east us" - reevaluate iterates internalExcludeRegions and consults the pre-built + // normalizedExcludes Set ({"eastus","westus"}). Pre-fix string-equal check + // ("east us".equals("eastus") -> false) would silently re-add East US to the retry + // list; the normalized check correctly recognises the match and blocks it. RxDocumentServiceRequest request = RxDocumentServiceRequest.create( mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); request.requestContext.setExcludeRegions(Arrays.asList("eastus", "westus")); @@ -2455,7 +2455,7 @@ public void reevaluate_nonCanonicalUserExclude_shouldNotReAddPpcbExcludedRegion( globalEndpointManager.getApplicableReadRegionalRoutingContexts(request); // East US is in both user-exclude and PPCB-internal-exclude lists. Without the - // containsNormalizedRegion fix the reevaluate loop would silently re-add East US; + // normalized-Set membership check the reevaluate loop would silently re-add East US; // with the fix the only applicable endpoint is Central US. for (RegionalRoutingContext endpoint : applicableEndpoints) { Assertions.assertThat(endpoint.getGatewayRegionalEndpoint()) 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 7051863c2c0c..d421bea21269 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 @@ -424,7 +424,7 @@ private UnmodifiableList getApplicableRegionRoutingConte new UnmodifiableList<>(applicableEndpoints), regionNameByRegionalRoutingContext, regionalRoutingContextByRegionName, - userConfiguredExcludeRegions, + normalizedExcludes, endpointsRemovedByInternalExcludeRegions, internalExcludeRegions, regionalRoutingContexts, @@ -439,8 +439,9 @@ private UnmodifiableList reevaluate( UnmodifiableList applicableRegionalRoutingContexts, UnmodifiableMap regionNameByRegionalRoutingContexts, UnmodifiableMap regionalRoutingContextsByRegionName, - // exclude regions from request options or client - List userConfiguredExcludeRegions, + // user-configured exclude regions, pre-normalized once in the caller and threaded through + // so downstream membership checks are O(1) Set.contains lookups instead of repeated walks + Set normalizedExcludes, // exclude URIs from per-partition circuit breaker List regionalRoutingContextsRemovedByInternalExcludeRegions, // exclude regions from per-partition circuit breaker @@ -486,7 +487,7 @@ private UnmodifiableList reevaluate( if (isFallbackRoutingContextUsed) { // user wishes to exclude all regions - use partition-set level primary region [or] account-level primary region // no cross region retries applicable - if (!userConfiguredExcludeRegions.isEmpty() && regionalRoutingContextsRemovedByInternalExcludeRegions.isEmpty()) { + if (!normalizedExcludes.isEmpty() && regionalRoutingContextsRemovedByInternalExcludeRegions.isEmpty()) { crossRegionAvailabilityContextForRequest.setShouldUsePerPartitionAutomaticFailoverOverrideForReadsIfApplicable(true); return applicableRegionalRoutingContexts; } @@ -523,9 +524,9 @@ private UnmodifiableList reevaluate( // Also honor the user-exclude list here so the global-fallback path does // not re-add a region the user explicitly excluded. Mirrors the guard in - // the sibling else-branch below. + // the sibling else-branch below. O(1) lookup against the pre-normalized set. if (!regionalRoutingContextValueHolder.v.equals(hubRoutingContext) - && !containsNormalizedRegion(userConfiguredExcludeRegions, RegionNameNormalizer.normalize(internalExcludeRegion))) { + && !normalizedExcludes.contains(this.normalizeExcludeRegion(internalExcludeRegion))) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -539,9 +540,9 @@ private UnmodifiableList reevaluate( if (Utils.tryGetValue(regionalRoutingContextsByRegionName, internalExcludeRegion, regionalRoutingContextValueHolder)) { // For the user-exclude membership check, compare in normalized form so // unknown regions in any input form (e.g., "plutocentral" vs "Pluto Central") - // match consistently. + // match consistently. O(1) lookup against the pre-normalized set. if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) - && !containsNormalizedRegion(userConfiguredExcludeRegions, RegionNameNormalizer.normalize(internalExcludeRegion))) { + && !normalizedExcludes.contains(this.normalizeExcludeRegion(internalExcludeRegion))) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -553,26 +554,6 @@ private UnmodifiableList reevaluate( return new UnmodifiableList<>(modifiedRegionalRoutingContexts); } - /** - * Checks whether {@code userExcludeRegions} contains {@code normalizedTarget} after normalizing - * each user-supplied entry. Uses the same normalization (lowercase + strip spaces/hyphens/underscores) - * that {@link #getApplicableRegionRoutingContexts} uses for the user-exclude check, so that unknown - * regions in any input form (e.g., "westus3" vs "West US 3") match consistently. - * Normalization of each user-supplied entry goes through {@link #normalizeExcludeRegion(String)} - * so the per-client cache is reused. - */ - private boolean containsNormalizedRegion(List userExcludeRegions, String normalizedTarget) { - if (userExcludeRegions == null || userExcludeRegions.isEmpty()) { - return false; - } - for (String region : userExcludeRegions) { - if (region != null && normalizedTarget.equals(this.normalizeExcludeRegion(region))) { - return true; - } - } - return false; - } - /** * Fast-path normalization for customer-supplied exclude region strings. Customers in the * common case pass the same exclude regions on every request, so caching From 2c74e3499b723e24ce1eecb58085b2c273aefb6e Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 1 Jun 2026 12:38:06 -0400 Subject: [PATCH 57/58] Use RegionNameNormalizer in getOrderedApplicableRegionsForSpeculation The ad-hoc normalizer (toLowerCase only) preserved hyphens/underscores/spaces, so customer-supplied exclude regions like "west-us-3" or "west_us_3" never matched the server-form "west us 3" returned by GlobalEndpointManager.getRegionName. Excluded regions would silently leak back into the availability strategy speculation order. Switch both the set-build and contains-check sites to RegionNameNormalizer.normalize(...) so the exclusion semantics match the rest of the region-name-mapper feature. Removes the now-unused java.util.Locale import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/cosmos/implementation/RxDocumentClientImpl.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 6de92037a0e5..11183bf31ac0 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 @@ -69,6 +69,7 @@ import com.azure.cosmos.implementation.routing.PartitionKeyRangeIdentity; import com.azure.cosmos.implementation.routing.Range; import com.azure.cosmos.implementation.routing.RegionIdRegistry; +import com.azure.cosmos.implementation.routing.RegionNameNormalizer; import com.azure.cosmos.implementation.routing.RegionalRoutingContext; import com.azure.cosmos.implementation.spark.OperationContext; import com.azure.cosmos.implementation.spark.OperationContextAndListenerTuple; @@ -125,7 +126,6 @@ import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; @@ -8574,13 +8574,13 @@ private List getApplicableRegionsForSpeculation( HashSet normalizedExcludedRegions = new HashSet<>(); if (excludedRegions != null) { - excludedRegions.forEach(r -> normalizedExcludedRegions.add(r.toLowerCase(Locale.ROOT))); + excludedRegions.forEach(r -> normalizedExcludedRegions.add(RegionNameNormalizer.normalize(r))); } List orderedRegionsForSpeculation = new ArrayList<>(); regionalRoutingContextList.forEach(consolidatedLocationEndpoints -> { String regionName = this.globalEndpointManager.getRegionName(consolidatedLocationEndpoints.getGatewayRegionalEndpoint(), operationType); - if (!normalizedExcludedRegions.contains(regionName.toLowerCase(Locale.ROOT))) { + if (!normalizedExcludedRegions.contains(RegionNameNormalizer.normalize(regionName))) { orderedRegionsForSpeculation.add(regionName); } }); From 934163670a968b0474f40bec1c69f20395c0334b Mon Sep 17 00:00:00 2001 From: Abhijeet Mohanty Date: Mon, 1 Jun 2026 13:13:44 -0400 Subject: [PATCH 58/58] Pre-size normalizedExcludes HashSet to avoid 75% load-factor resize Per Annie's suggestion on PR #49090: capacity (n*4)/3+1 ensures the set holds n entries without triggering rehash at the default 0.75 load factor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../com/azure/cosmos/implementation/routing/LocationCache.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d421bea21269..fe09800f1773 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 @@ -371,7 +371,7 @@ private UnmodifiableList getApplicableRegionRoutingConte if (userConfiguredExcludeRegions == null || userConfiguredExcludeRegions.isEmpty()) { normalizedExcludes = Collections.emptySet(); } else { - normalizedExcludes = new HashSet<>(userConfiguredExcludeRegions.size()); + normalizedExcludes = new HashSet<>((userConfiguredExcludeRegions.size() * 4) / 3 + 1); for (String excludeRegion : userConfiguredExcludeRegions) { if (excludeRegion != null) { normalizedExcludes.add(this.normalizeExcludeRegion(excludeRegion));