Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
cbde001
Port RegionNameMapper from .NET SDK to normalize region names
jeet1995 May 7, 2026
2afefb9
Add dynamic region registration from server responses
jeet1995 May 7, 2026
25a47ea
Fix merge conflict markers left in RegionNameMapper and test
jeet1995 May 7, 2026
849bcf3
Consolidate RegionNameMapper into RegionNameToRegionIdMap
jeet1995 May 7, 2026
1d07697
Add integration tests for region name normalization in LocationCache
jeet1995 May 7, 2026
e668ec4
Add e2e tests for region normalization in ExcludeRegionTests
jeet1995 May 7, 2026
a5897e9
Add e2e tests for region normalization in availability strategy and PPCB
jeet1995 May 7, 2026
6083eac
Remove dynamic region registration — pass unknown regions as-is
jeet1995 May 7, 2026
5b7cd2b
Remove dedupe from setPreferredRegions — let customer misconfig be vi…
jeet1995 May 7, 2026
d16d101
Fix stale javadoc referencing dynamic registration + add CHANGELOG entry
jeet1995 May 7, 2026
b6a56c0
Sync canonical region list with LocationNames.cs
jeet1995 May 7, 2026
5005ced
Sync region ID map with authoritative Settings.xml regionToIdMapping
jeet1995 May 7, 2026
06351d1
Rename RegionNameToRegionIdMap → RegionUtils, move helpers out of Loc…
jeet1995 May 7, 2026
71d9f8e
Address PR review comments
jeet1995 May 7, 2026
0a2c882
Address PR review: preserve customer input in public API, merge stati…
jeet1995 May 11, 2026
4c02812
Push normalization into getRegionId with fast-path, restore region lo…
jeet1995 May 11, 2026
659717b
Add region name normalization cache for exclude regions in LocationCa…
jeet1995 May 11, 2026
17cd14b
Fix blank string test to match as-is fallback behavior
jeet1995 May 11, 2026
dd71bd8
Rewrite E2E tests: add fault injection to PPCB, availability strategy…
jeet1995 May 11, 2026
1ac9808
Add unknown region integration tests for LocationCache — validates ro…
jeet1995 May 11, 2026
d14a4d9
Add duplicate ID fail-fast in RegionUtils static block, add PPCB reev…
jeet1995 May 11, 2026
c3b4052
Normalize region suffix in LocationHelper to lowercase for consistenc…
jeet1995 May 11, 2026
dbb51b1
Centralize normalization: normalize preferred regions once at Connect…
jeet1995 May 11, 2026
1d97a9f
Fix AssertJ compilation: use Set.contains() instead of assertThat().c…
jeet1995 May 11, 2026
027e32b
Rename variables to distinguish canonical vs normalized: getCanonical…
jeet1995 May 11, 2026
4ef31d1
Make RegionUtils explicit about normalized vs canonical in all javado…
jeet1995 May 11, 2026
6be7bfc
Fix stale getCosmosDBRegionName reference, clarify canonical vs norma…
jeet1995 May 11, 2026
6f72c2c
Make CHANGELOG entry more concise
jeet1995 May 11, 2026
ae50812
Rename REGION_NAME_TO_REGION_ID_MAPPINGS to CANONICAL_REGION_NAME_TO_…
jeet1995 May 11, 2026
5b43ee5
Simplify: scope all canonicalization to LocationCache, revert Connect…
jeet1995 May 11, 2026
8649bca
Remove System.clearProperty for PPCB config in test finally block
jeet1995 May 11, 2026
47562cd
Revert ConnectionPolicy.java to main — no changes needed
jeet1995 May 11, 2026
5b960a6
Rename getRegionName(int) to getNormalizedRegionName(int) for consist…
jeet1995 May 11, 2026
d6e8f2b
Revert accidental changes to CosmosTracerTest, ChangeFeedState, Chang…
jeet1995 May 11, 2026
d9c3cff
Refactoring
jeet1995 May 11, 2026
348701c
Refactoring
jeet1995 May 11, 2026
804cc3f
Remove hardcoded DIRECT connectionType from fault injection rule — su…
jeet1995 May 12, 2026
3e875ca
Remove hardcoded DIRECT connectionType from PPCB test fault injection…
jeet1995 May 12, 2026
2df0dc5
Fixing tests.
jeet1995 May 12, 2026
0d1e64a
Fixing tests.
jeet1995 May 12, 2026
7983f09
Normalize hyphens and underscores in region names: west-us-3, west_us…
jeet1995 May 14, 2026
be5b644
Add deduped warning for preferred regions that don't match any accoun…
jeet1995 May 27, 2026
a56a614
Improve LocationHelper dataCenterToUriPostfix comment with RFC 4343 r…
jeet1995 May 27, 2026
54024ac
Merge remote-tracking branch 'upstream/main' into squad/region-name-m…
jeet1995 May 27, 2026
3a7f99a
Fix LocationHelperTest — update expected URL to match normalized form…
jeet1995 May 27, 2026
aa66328
Add Spark E2E test verifying region-name-mapper is wired through shad…
jeet1995 May 29, 2026
7bd49d2
Address LocationCache review blockers: bounded exclude normalization,…
jeet1995 May 29, 2026
44bd05d
Collapse exclude-region check to single pass with HashSet (O(1) membe…
jeet1995 May 29, 2026
779bbd7
Refactor RegionUtils into pure normalizer + map-aware registry
jeet1995 May 29, 2026
90a6e58
Honor user-exclude in global-fallback branch of getApplicableRegionRo…
jeet1995 May 30, 2026
756f500
Migrate residual ad-hoc region normalizers to RegionNameNormalizer
jeet1995 May 30, 2026
b5f79f7
Address xinlian12 review comments
jeet1995 May 30, 2026
9551b01
Cache raw -> normalized exclude region on the per-request hot path
jeet1995 May 30, 2026
b240f22
Add unit tests for exclude-region normalization cache
jeet1995 May 30, 2026
7d3a4e0
Merge branch 'main' into squad/region-name-mapper
jeet1995 May 30, 2026
ef2b81b
Serialize Cosmos Build stage to dodge scala-maven-plugin 4.8.1 race
jeet1995 May 31, 2026
1e79bab
Revert "Serialize Cosmos Build stage to dodge scala-maven-plugin 4.8.…
jeet1995 May 31, 2026
26c99f4
Thread pre-normalized exclude Set through reevaluate; drop containsNo…
jeet1995 May 31, 2026
4e129a9
Merge branch 'main' into squad/region-name-mapper
FabianMeiswinkel Jun 1, 2026
2c74e34
Use RegionNameNormalizer in getOrderedApplicableRegionsForSpeculation
jeet1995 Jun 1, 2026
918af4c
Merge branch 'main' into squad/region-name-mapper
jeet1995 Jun 1, 2026
9341636
Pre-size normalizedExcludes HashSet to avoid 75% load-factor resize
jeet1995 Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading