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) + } + } +} 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..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 @@ -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"); } @@ -137,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, @@ -234,6 +255,78 @@ public void excludeRegionTest_readSessionNotAvailable( } } + @Test(groups = {"multi-master"}, dataProvider = "operationTypeArgProvider", timeOut = TIMEOUT) + public void excludeRegionTest_nonCanonicalPreferredRegions_shouldRouteCorrectly(OperationType operationType) throws InterruptedException { + + if (this.nonCanonicalPreferredRegionList.size() <= 1) { + throw new SkipException("Test requires multi-master with multi-regions"); + } + + // Verify that a client built with space-stripped region names (e.g., "westus3") + // routes to the correct first preferred region — same as canonical names + TestObject createdItem = TestObject.create(); + this.cosmosAsyncContainerNonCanonical.createItem(createdItem).block(); + + Thread.sleep(2000); + + CosmosDiagnosticsContext diagnostics = this.performDocumentOperation( + cosmosAsyncContainerNonCanonical, operationType, createdItem, null, INF_E2E_TIMEOUT); + + // The contacted region should match the first preferred region (lowercased canonical form) + validateRegionsContacted(diagnostics, this.preferredRegionList.subList(0, 1)); + } + + @Test(groups = {"multi-master"}, dataProvider = "operationTypeArgProvider", timeOut = TIMEOUT) + public void excludeRegionTest_nonCanonicalExcludeRegion_shouldSkipExcludedRegion(OperationType operationType) throws InterruptedException { + + if (this.preferredRegionList.size() <= 1) { + throw new SkipException("Test requires multi-master with multi-regions"); + } + + TestObject createdItem = TestObject.create(); + this.cosmosAsyncContainerNonCanonical.createItem(createdItem).block(); + + Thread.sleep(2000); + + // Exclude the first preferred region using space-stripped name (e.g., "westus3") + String firstRegionNoSpaces = this.preferredRegionList.get(0).replace(" ", ""); + + CosmosDiagnosticsContext diagnosticsPostExclusion = this.performDocumentOperation( + cosmosAsyncContainerNonCanonical, + operationType, + createdItem, + Arrays.asList(firstRegionNoSpaces), + INF_E2E_TIMEOUT); + + // Should route to the second preferred region, not the first + validateRegionsContacted(diagnosticsPostExclusion, this.preferredRegionList.subList(1, 2)); + } + + @Test(groups = {"multi-master"}, timeOut = TIMEOUT) + public void excludeRegionTest_uppercaseExcludeRegion_shouldSkipExcludedRegion() throws InterruptedException { + + if (this.preferredRegionList.size() <= 1) { + throw new SkipException("Test requires multi-master with multi-regions"); + } + + TestObject createdItem = TestObject.create(); + this.cosmosAsyncContainer.createItem(createdItem).block(); + + Thread.sleep(2000); + + // Exclude the first preferred region using UPPERCASE name + String firstRegionUppercase = this.preferredRegionList.get(0).toUpperCase(); + + CosmosDiagnosticsContext diagnostics = this.performDocumentOperation( + cosmosAsyncContainer, + OperationType.Read, + createdItem, + Arrays.asList(firstRegionUppercase), + INF_E2E_TIMEOUT); + + validateRegionsContacted(diagnostics, this.preferredRegionList.subList(1, 2)); + } + private List getPreferredRegionList(CosmosAsyncClient client) { assertThat(client).isNotNull(); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/FaultInjectionWithAvailabilityStrategyTestsBase.java index 296a1c06b7ac..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 @@ -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,111 @@ 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 with availability strategy — fault injection in first region + // should cause failover to second region via hedging + + 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); + + // Step 1: Read without fault injection — should contact first preferred region + 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()) + .as("Without faults, should contact first preferred region") + .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(); + FaultInjectionServerErrorResult serviceUnavailableResult = FaultInjectionResultBuilders + .getResultBuilder(FaultInjectionServerErrorType.SERVICE_UNAVAILABLE) + .delay(Duration.ofMillis(5)) + .build(); + + FaultInjectionRule faultRule = new FaultInjectionRuleBuilder(ruleName) + .condition( + new FaultInjectionConditionBuilder() + .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) + .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(); + } + } 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..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 @@ -5267,4 +5267,111 @@ 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 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\": 10," + + "\"consecutiveExceptionCountToleratedForWrites\": 5," + + "}"); + + 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); + + // Bootstrap: create a test item + TestObject testObject = TestObject.create(); + container.createItem(testObject, new PartitionKey(testObject.getId()), new CosmosItemRequestOptions()).block(); + + // Step 1: Inject 503 (ServiceUnavailable) into the first preferred region for READ_ITEM + FaultInjectionCondition faultCondition = new FaultInjectionConditionBuilder() + .region(this.writeRegions.get(0)) + .operationType(FaultInjectionOperationType.READ_ITEM) + .build(); + + FaultInjectionServerErrorResult serverError = FaultInjectionResultBuilders + .getResultBuilder(FaultInjectionServerErrorType.SERVICE_UNAVAILABLE) + .build(); + + 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(); + + // 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(); + + assertThat(readResponse).isNotNull(); + assertThat(readResponse.getStatusCode()).isEqualTo(200); + + 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(); + } + } + } } 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); } } 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 new file mode 100644 index 000000000000..6826793db44d --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionIdRegistryTests.java @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +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.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +public class RegionIdRegistryTests { + + private static final Logger logger = LoggerFactory.getLogger(RegionIdRegistryTests.class); + + @Test(groups = {"unit"}) + public void regionIdToRegionNameConsistency() { + + for (Map.Entry sourceEntry : RegionIdRegistry.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS.entrySet()) { + // 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); + + 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(RegionIdRegistry.NORMALIZED_REGION_NAME_TO_REGION_ID_MAPPINGS.get(normalizedRegionNameFromSource)).isEqualTo(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(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/RegionNameToRegionIdMapTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionNameToRegionIdMapTests.java deleted file mode 100644 index 66b64fd91d4d..000000000000 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RegionNameToRegionIdMapTests.java +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.cosmos.implementation; - -import com.azure.cosmos.SessionConsistencyWithRegionScopingTests; -import com.azure.cosmos.implementation.routing.RegionNameToRegionIdMap; -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; - -public class RegionNameToRegionIdMapTests { - - private static final Logger logger = LoggerFactory.getLogger(RegionNameToRegionIdMap.class); - - @Test(groups = {"unit"}) - public void regionIdToRegionNameConsistency() { - - for (Map.Entry sourceEntry : RegionNameToRegionIdMap.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)) - .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(RegionNameToRegionIdMap.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); - } - } -} 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..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 @@ -2380,4 +2380,97 @@ 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 { + // 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 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")); + 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 is in both user-exclude and PPCB-internal-exclude lists. Without the + // 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()) + .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); + } + + Assertions.assertThat(applicableEndpoints).hasSize(1); + Assertions.assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()) + .isEqualTo(TestAccountCentralUsEndpoint); + } finally { + globalEndpointManager.close(); + } + } } 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..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 @@ -948,4 +948,410 @@ private static DatabaseAccountLocation createDatabaseAccountLocation(String name return dal; } + + // ======================================================================== + // Region name normalization integration tests + // ======================================================================== + + private static final URI WestUS3Endpoint = createUrl("https://westus3.documents.azure.com"); + private static final URI EastUSEndpoint = createUrl("https://eastus.documents.azure.com"); + private static final URI NorthEuropeEndpoint = createUrl("https://northeurope.documents.azure.com"); + + private static DatabaseAccount createDatabaseAccountWithRealRegions() { + return ModelBridgeUtils.createDatabaseAccount( + // read endpoints (server returns canonical names) + ImmutableList.of( + createDatabaseAccountLocation("West US 3", WestUS3Endpoint.toString()), + createDatabaseAccountLocation("East US", EastUSEndpoint.toString()), + createDatabaseAccountLocation("North Europe", NorthEuropeEndpoint.toString())), + // write endpoints + ImmutableList.of( + createDatabaseAccountLocation("West US 3", WestUS3Endpoint.toString()), + createDatabaseAccountLocation("East US", EastUSEndpoint.toString()), + createDatabaseAccountLocation("North Europe", NorthEuropeEndpoint.toString())), + true); + } + + private LocationCache createCacheWithRealRegions(List preferredRegions) { + ConnectionPolicy connectionPolicy = new ConnectionPolicy(DirectConnectionConfig.getDefaultConfig()); + connectionPolicy.setEndpointDiscoveryEnabled(true); + connectionPolicy.setMultipleWriteRegionsEnabled(true); + connectionPolicy.setPreferredRegions(preferredRegions); + + LocationCache locationCache = new LocationCache( + connectionPolicy, + DefaultEndpoint, + configs); + + locationCache.onDatabaseAccountRead(createDatabaseAccountWithRealRegions()); + return locationCache; + } + + @Test(groups = "unit") + public void preferredRegions_lowercaseShouldMatchCanonical() { + // Customer passes "west us 3" (all lowercase) instead of "West US 3" + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("west us 3", "east us", "north europe")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(NorthEuropeEndpoint); + } + + @Test(groups = "unit") + public void preferredRegions_noSpacesShouldMatchCanonical() { + // Customer passes "westus3" (no spaces) instead of "West US 3" + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("westus3", "eastus", "northeurope")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(NorthEuropeEndpoint); + } + + @Test(groups = "unit") + public void preferredRegions_uppercaseShouldMatchCanonical() { + // Customer passes "WEST US 3" (all uppercase) + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("WEST US 3", "EAST US", "NORTH EUROPE")); + + UnmodifiableList readEndpoints = locationCache.getReadEndpoints(); + assertThat(readEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(readEndpoints.get(1).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + assertThat(readEndpoints.get(2).getGatewayRegionalEndpoint()).isEqualTo(NorthEuropeEndpoint); + } + + @Test(groups = "unit") + public void excludeRegions_lowercaseNoSpacesShouldExclude() { + // Preferred regions in canonical form, exclude region with no spaces + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + + AtomicReference excludedRef = new AtomicReference<>( + new CosmosExcludedRegions(new HashSet<>(Arrays.asList("westus3")))); + + ConnectionPolicy connectionPolicy = ReflectionUtils.getConnectionPolicy(locationCache); + connectionPolicy.setExcludedRegionsSupplier(excludedRef::get); + + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + + List applicableEndpoints = + locationCache.getApplicableReadRegionRoutingContexts(request); + + // "westus3" should exclude "West US 3" + assertThat(applicableEndpoints.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(WestUS3Endpoint); + assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(EastUSEndpoint); + } + + @Test(groups = "unit") + public void excludeRegions_mixedCasingShouldExclude() { + // Exclude with weird casing: "EAST us", "north EUROPE" + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + + AtomicReference excludedRef = new AtomicReference<>( + new CosmosExcludedRegions(new HashSet<>(Arrays.asList("EAST us", "north EUROPE")))); + + ConnectionPolicy connectionPolicy = ReflectionUtils.getConnectionPolicy(locationCache); + connectionPolicy.setExcludedRegionsSupplier(excludedRef::get); + + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + + List applicableEndpoints = + locationCache.getApplicableReadRegionRoutingContexts(request); + + // Only West US 3 should remain + assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + assertThat(applicableEndpoints.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(EastUSEndpoint) + .doesNotContain(NorthEuropeEndpoint); + } + + @Test(groups = "unit") + public void excludeRegions_requestLevelNoSpacesShouldExclude() { + // Request-level exclude with space-stripped names + LocationCache locationCache = createCacheWithRealRegions( + Arrays.asList("West US 3", "East US", "North Europe")); + + RxDocumentServiceRequest request = RxDocumentServiceRequest.create( + mockDiagnosticsClientContext(), OperationType.Read, ResourceType.Document); + request.requestContext.setExcludeRegions(Arrays.asList("eastus")); + + List applicableEndpoints = + locationCache.getApplicableReadRegionRoutingContexts(request); + + assertThat(applicableEndpoints.stream() + .map(RegionalRoutingContext::getGatewayRegionalEndpoint) + .collect(Collectors.toList())) + .doesNotContain(EastUSEndpoint); + assertThat(applicableEndpoints.get(0).getGatewayRegionalEndpoint()).isEqualTo(WestUS3Endpoint); + } + + // ======================================================================== + // 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); + } + // ======================================================================== + // 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); + } } diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionIdRegistrySettingsXmlValidationTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionIdRegistrySettingsXmlValidationTest.java new file mode 100644 index 000000000000..04f04c526f2f --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/routing/RegionIdRegistrySettingsXmlValidationTest.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 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 + * {@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 RegionIdRegistrySettingsXmlValidationTest { + + private static final Logger logger = LoggerFactory.getLogger(RegionIdRegistrySettingsXmlValidationTest.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 regionIdRegistryMappingMatchesSettingsXml() throws Exception { + + Map settingsXmlMapping = parseRegionToIdMappingFromResource(); + + Map sdkMapping = RegionIdRegistry.CANONICAL_REGION_NAME_TO_REGION_ID_MAPPINGS; + + List errors = new ArrayList<>(); + + // 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 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, RegionIdRegistry has ID=%d", + region, expectedId, sdkMapping.get(region))); + } + } + + // 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 RegionIdRegistry: '%s' (ID=%d) is not in Settings.xml — stale entry?", + actual.getKey(), actual.getValue())); + } + } + + if (!errors.isEmpty()) { + StringBuilder sb = new StringBuilder(); + 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 RegionIdRegistry.java"); + + assertThat(errors).as(sb.toString()).isEmpty(); + } + + logger.info("RegionIdRegistry 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 @@ + diff --git a/sdk/cosmos/azure-cosmos/CHANGELOG.md b/sdk/cosmos/azure-cosmos/CHANGELOG.md index 5180cd9a9b71..66f38301cb6c 100644 --- a/sdk/cosmos/azure-cosmos/CHANGELOG.md +++ b/sdk/cosmos/azure-cosmos/CHANGELOG.md @@ -8,6 +8,7 @@ #### Breaking Changes #### Bugs Fixed +* 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) * Fixed `UnsupportedOperationException` when using `readManyByPartitionKeys` for empty pages. - See [PR 49311](https://github.com/Azure/azure-sdk-for-java/pull/49311) #### Other Changes 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 c88955c78ea4..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 @@ -8,13 +8,13 @@ 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.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; @@ -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<>(); @@ -122,11 +121,11 @@ 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); - int regionId = RegionNameToRegionIdMap.getRegionId(normalizedRegionRoutedTo); + int regionId = RegionIdRegistry.getRegionId(normalizedRegionRoutedTo); if (regionId != -1) { long localLsn = localLsnByRegion.v.getOrDefault(regionId, Long.MIN_VALUE); @@ -355,7 +354,7 @@ public ISessionToken tryResolveSessionToken( for (Map.Entry localLsnByRegionEntry : localLsnByRegion.v.entrySet()) { int regionId = localLsnByRegionEntry.getKey(); - String normalizedRegionName = RegionNameToRegionIdMap.getRegionName(regionId); + String normalizedRegionName = RegionIdRegistry.getNormalizedRegionNameForId(regionId); // the regionId to normalizedRegionName does not exist if (normalizedRegionName.equals(StringUtils.EMPTY)) { @@ -401,7 +400,7 @@ public ISessionToken tryResolveSessionToken( // Obtain globalLsn from hub region for (String lesserPreferredRegionPkProbablyRequestedFrom : lesserPreferredRegionsPkProbablyRequestedFrom) { - int regionId = RegionNameToRegionIdMap.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/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!"); 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..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 @@ -68,7 +68,8 @@ 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.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; @@ -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 (RegionNameToRegionIdMap.getRegionId(normalizedReadableRegion) == -1) { + if (RegionIdRegistry.getRegionId(readableLocation.getName()) == -1) { return false; } } @@ -8576,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); } }); 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..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 @@ -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; @@ -51,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; @@ -76,6 +94,7 @@ public LocationCache( this.lockObject = new Object(); this.locationUnavailabilityInfoByEndpoint = new ConcurrentHashMap<>(); + this.rawToNormalizedExcludeRegionCache = new ConcurrentHashMap<>(); this.lastCacheUpdateTimestamp = Instant.MIN; this.enableMultipleWriteLocations = false; @@ -345,13 +364,26 @@ private UnmodifiableList getApplicableRegionRoutingConte List endpointsRemovedByInternalExcludeRegions = new ArrayList<>(); List applicableEndpoints = new ArrayList<>(); - // exclude those regions which are user excluded first + // 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()) { + normalizedExcludes = Collections.emptySet(); + } else { + normalizedExcludes = new HashSet<>((userConfiguredExcludeRegions.size() * 4) / 3 + 1); + for (String excludeRegion : userConfiguredExcludeRegions) { + if (excludeRegion != null) { + normalizedExcludes.add(this.normalizeExcludeRegion(excludeRegion)); + } + } + } + for (RegionalRoutingContext endpoint : regionalRoutingContexts) { Utils.ValueHolder regionName = new Utils.ValueHolder<>(); - if (Utils.tryGetValue(regionNameByRegionalRoutingContext, endpoint, regionName)) { - if (!userConfiguredExcludeRegions.stream().anyMatch(regionName.v::equalsIgnoreCase)) { - applicableEndpoints.add(endpoint); - } + if (Utils.tryGetValue(regionNameByRegionalRoutingContext, endpoint, regionName) + && !normalizedExcludes.contains(RegionNameNormalizer.normalize(regionName.v))) { + applicableEndpoints.add(endpoint); } } @@ -392,7 +424,7 @@ private UnmodifiableList getApplicableRegionRoutingConte new UnmodifiableList<>(applicableEndpoints), regionNameByRegionalRoutingContext, regionalRoutingContextByRegionName, - userConfiguredExcludeRegions, + normalizedExcludes, endpointsRemovedByInternalExcludeRegions, internalExcludeRegions, regionalRoutingContexts, @@ -407,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 @@ -454,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; } @@ -485,9 +518,15 @@ 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)) { + // 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. O(1) lookup against the pre-normalized set. + if (!regionalRoutingContextValueHolder.v.equals(hubRoutingContext) + && !normalizedExcludes.contains(this.normalizeExcludeRegion(internalExcludeRegion))) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -499,7 +538,11 @@ private UnmodifiableList reevaluate( Utils.ValueHolder regionalRoutingContextValueHolder = new Utils.ValueHolder<>(null); if (Utils.tryGetValue(regionalRoutingContextsByRegionName, internalExcludeRegion, regionalRoutingContextValueHolder)) { - if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) && !userConfiguredExcludeRegions.contains(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. O(1) lookup against the pre-normalized set. + if (!regionalRoutingContextValueHolder.v.equals(firstApplicableRegionalRoutingContext) + && !normalizedExcludes.contains(this.normalizeExcludeRegion(internalExcludeRegion))) { modifiedRegionalRoutingContexts.add(regionalRoutingContextValueHolder.v); break; } @@ -511,6 +554,22 @@ private UnmodifiableList reevaluate( return new UnmodifiableList<>(modifiedRegionalRoutingContexts); } + /** + * 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()); @@ -566,7 +625,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 = RegionNameNormalizer.normalize(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))) { @@ -608,7 +672,8 @@ public boolean shouldRefreshEndpoints(Utils.ValueHolder canRefreshInBac return shouldRefresh; } } else if (!Strings.isNullOrEmpty(mostPreferredLocation)) { - if (Utils.tryGetValue(currentLocationInfo.availableWriteRegionalRoutingContextsByRegionName, mostPreferredLocation, mostPreferredWriteEndpointHolder)) { + String normalizedMostPreferredLocationForWrite = RegionNameNormalizer.normalize(mostPreferredLocation); + if (Utils.tryGetValue(currentLocationInfo.availableWriteRegionalRoutingContextsByNormalizedRegionName, normalizedMostPreferredLocationForWrite, mostPreferredWriteEndpointHolder)) { shouldRefresh = ! areEqual(mostPreferredWriteEndpointHolder.v,writeLocationEndpoints.get(0)); if (shouldRefresh) { @@ -786,11 +851,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); } @@ -798,16 +865,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()) { @@ -828,6 +897,7 @@ private void updateLocationCache( } private UnmodifiableList getPreferredAvailableRoutingContexts(UnmodifiableMap endpointsByLocation, + UnmodifiableMap endpointsByNormalizedLocation, UnmodifiableList orderedLocations, OperationType expectedAvailableOperation, RegionalRoutingContext fallbackRegionalRoutingContext) { @@ -845,8 +915,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 { @@ -901,6 +974,7 @@ private void addRoutingContexts( Iterable gatewayDbAccountLocations, Iterable thinclientDbAccountLocations, Map endpointsByLocation, + Map endpointsByNormalizedLocation, Map regionByEndpoint, List parsedLocations, List orderedEndpoints) { @@ -918,6 +992,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 = RegionNameNormalizer.normalize(gatewayDbAccountLocation.getName()); + if (!endpointsByNormalizedLocation.containsKey(normalizedLocation)) { + endpointsByNormalizedLocation.put(normalizedLocation, regionalRoutingContext); + } if (!regionByEndpoint.containsKey(regionalRoutingContext)) { regionByEndpoint.put(regionalRoutingContext, location); @@ -960,17 +1042,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); } @@ -1050,14 +1135,32 @@ 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 + // 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; @@ -1066,12 +1169,22 @@ static class DatabaseAccountLocationsInfo { public DatabaseAccountLocationsInfo(List preferredLocations, RegionalRoutingContext defaultRoutingContext) { - this.preferredLocations = new UnmodifiableList<>(preferredLocations.stream().map(loc -> loc.toLowerCase(Locale.ROOT)).collect(Collectors.toList())); + // 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(RegionNameNormalizer::normalize) + .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 @@ -1090,6 +1203,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; 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..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 @@ -45,7 +45,12 @@ public static URI getLocationEndpoint(URI serviceEndpoint, String location) { } private static String dataCenterToUriPostfix(String dataCenter) { - return dataCenter.replace(" ", ""); + // 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 + // 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/RegionNameToRegionIdMap.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMap.java deleted file mode 100644 index cfd522641888..000000000000 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/routing/RegionNameToRegionIdMap.java +++ /dev/null @@ -1,262 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package com.azure.cosmos.implementation.routing; - -import com.azure.cosmos.implementation.apachecommons.lang.StringUtils; - -import java.util.HashMap; -import java.util.Map; - -/** - * ATTENTION: Please ensure the below map is consistent with RegionToIdMap.cs to avoid breaking behavior. - *

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