From 010de6cc00e1ac8cd4176caa5ff3a2edde36c4d4 Mon Sep 17 00:00:00 2001 From: Nalu Tripician <27316859+NaluTripician@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:59:25 -0800 Subject: [PATCH 1/7] Fix null contacted region name on multimaster default endpoint fallback --- .../src/Routing/LocationCache.cs | 279 +++++++++--------- .../LocationCacheTests.cs | 128 ++++---- 2 files changed, 215 insertions(+), 192 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs index 11a1ea4f7b..60d12d0747 100644 --- a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs +++ b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs @@ -124,7 +124,7 @@ public ReadOnlyCollection WriteEndpoints return this.locationInfo.WriteEndpoints; } } - + /// /// Gets the list of thin client read endpoints. /// @@ -141,7 +141,7 @@ public ReadOnlyCollection ThinClientReadEndpoints return this.locationInfo.ThinClientReadEndpoints; } - } + } /// /// Gets the list of thin client write endpoints. @@ -159,7 +159,7 @@ public ReadOnlyCollection ThinClientWriteEndpoints return this.locationInfo.ThinClientWriteEndpoints; } - } + } public ReadOnlyCollection EffectivePreferredLocations => this.locationInfo.EffectivePreferredLocations; @@ -168,15 +168,11 @@ public ReadOnlyCollection ThinClientWriteEndpoints /// For the defaultEndPoint, we will return the first available write location. /// Returns null, in other cases. /// - /// - /// Today we return null for defaultEndPoint if multiple write locations can be used. - /// This needs to be modifed to figure out proper location in such case. - /// public string GetLocation(Uri endpoint) { string location = this.locationInfo.AvailableWriteEndpointByLocation.FirstOrDefault(uri => uri.Value == endpoint).Key ?? this.locationInfo.AvailableReadEndpointByLocation.FirstOrDefault(uri => uri.Value == endpoint).Key; - if (location == null && endpoint == this.defaultEndpoint && !this.CanUseMultipleWriteLocations()) + if (location == null && endpoint == this.defaultEndpoint) { if (this.locationInfo.AvailableWriteEndpointByLocation.Any()) { @@ -189,7 +185,8 @@ public string GetLocation(Uri endpoint) /// /// Set region name for a location if present in the locationcache otherwise set region name as null. - /// If endpoint's hostname is same as default endpoint hostname, set regionName as null. + /// For multi-master accounts, if endpoint's hostname is same as default endpoint hostname, + /// set regionName to the write hub region. /// /// /// @@ -203,12 +200,18 @@ public bool TryGetLocationForGatewayDiagnostics(Uri endpoint, out string regionN UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase) == 0) { + if (this.CanUseMultipleWriteLocations()) + { + regionName = this.GetLocation(this.defaultEndpoint); + return regionName != null; + } + regionName = null; return false; } regionName = this.GetLocation(endpoint); - return true; + return regionName != null; } /// @@ -235,9 +238,9 @@ public void OnDatabaseAccountRead(AccountProperties databaseAccount) { this.UpdateLocationCache( databaseAccount.WritableRegions, - databaseAccount.ReadableRegions, - thinClientWriteLocations: databaseAccount.ThinClientWritableLocationsInternal, - thinClientReadLocations: databaseAccount.ThinClientReadableLocationsInternal, + databaseAccount.ReadableRegions, + thinClientWriteLocations: databaseAccount.ThinClientWritableLocationsInternal, + thinClientReadLocations: databaseAccount.ThinClientReadableLocationsInternal, preferenceList: null, enableMultipleWriteLocations: databaseAccount.EnableMultipleWriteLocations); } @@ -313,7 +316,7 @@ public ReadOnlyCollection GetAvailableAccountLevelReadLocations() public ReadOnlyCollection GetAvailableAccountLevelWriteLocations() { return this.locationInfo.AvailableWriteLocations; - } + } /// /// Resolves request to service endpoint. @@ -391,17 +394,17 @@ public ReadOnlyCollection GetApplicableRegions(IEnumerable exclu { DatabaseAccountLocationsInfo databaseAccountLocationsInfoSnapshot = this.locationInfo; - ReadOnlyCollection effectivePreferredLocations = this.locationInfo.EffectivePreferredLocations; - - if (effectivePreferredLocations == null || effectivePreferredLocations.Count == 0) - { - throw new ArgumentException("effectivePreferredLocations cannot be null or empty!"); + ReadOnlyCollection effectivePreferredLocations = this.locationInfo.EffectivePreferredLocations; + + if (effectivePreferredLocations == null || effectivePreferredLocations.Count == 0) + { + throw new ArgumentException("effectivePreferredLocations cannot be null or empty!"); } - - return GetApplicableRegions( - isReadRequest ? databaseAccountLocationsInfoSnapshot.AvailableReadLocations : databaseAccountLocationsInfoSnapshot.AvailableWriteLocations, - effectivePreferredLocations, - effectivePreferredLocations[0], + + return GetApplicableRegions( + isReadRequest ? databaseAccountLocationsInfoSnapshot.AvailableReadLocations : databaseAccountLocationsInfoSnapshot.AvailableWriteLocations, + effectivePreferredLocations, + effectivePreferredLocations[0], excludeRegions); } @@ -420,19 +423,19 @@ private static ReadOnlyCollection GetApplicableEndpoints( IEnumerable excludeRegions) { List applicableEndpoints = new List(regionNameByEndpoint.Count); - HashSet excludeRegionsHash = excludeRegions == null ? new HashSet() : new HashSet(excludeRegions); - - foreach (string region in effectivePreferredLocations) - { - if (excludeRegionsHash.Count > 0) - { - if (!excludeRegionsHash.Contains(region) && regionNameByEndpoint.TryGetValue(region, out Uri endpoint)) - { - applicableEndpoints.Add(endpoint); - } - } + HashSet excludeRegionsHash = excludeRegions == null ? new HashSet() : new HashSet(excludeRegions); + + foreach (string region in effectivePreferredLocations) + { + if (excludeRegionsHash.Count > 0) + { + if (!excludeRegionsHash.Contains(region) && regionNameByEndpoint.TryGetValue(region, out Uri endpoint)) + { + applicableEndpoints.Add(endpoint); + } + } else - { + { if (regionNameByEndpoint.TryGetValue(region, out Uri endpoint)) { applicableEndpoints.Add(endpoint); @@ -593,8 +596,8 @@ public bool CanUseMultipleWriteLocations(DocumentServiceRequest request) return this.CanUseMultipleWriteLocations() && (request.ResourceType == ResourceType.Document || (request.ResourceType == ResourceType.StoredProcedure && request.OperationType == Documents.OperationType.ExecuteJavaScript)); - } - + } + private void ClearStaleEndpointUnavailabilityInfo() { if (this.locationUnavailablityInfoByEndpoint.Any()) @@ -676,14 +679,14 @@ private void MarkEndpointUnavailable( unavailableEndpoint, unavailableOperationType, updatedInfo.LastUnavailabilityCheckTimeStamp); - } - - private void UpdateLocationCache( - IEnumerable writeLocations = null, - IEnumerable readLocations = null, - IEnumerable thinClientWriteLocations = null, - IEnumerable thinClientReadLocations = null, - ReadOnlyCollection preferenceList = null, + } + + private void UpdateLocationCache( + IEnumerable writeLocations = null, + IEnumerable readLocations = null, + IEnumerable thinClientWriteLocations = null, + IEnumerable thinClientReadLocations = null, + ReadOnlyCollection preferenceList = null, bool? enableMultipleWriteLocations = null) { lock (this.lockObject) @@ -723,28 +726,28 @@ private void UpdateLocationCache( nextLocationInfo.AvailableWriteLocations = availableWriteLocations; nextLocationInfo.AvailableWriteLocationByEndpoint = availableWriteLocationsByEndpoint; - } - - if (thinClientReadLocations != null && thinClientReadLocations.Count() > 0) - { - nextLocationInfo.ThinClientReadEndpointByLocation = this.GetEndpointByLocation( - thinClientReadLocations, - out ReadOnlyCollection thinClientAvailableReadLocations, - out ReadOnlyDictionary thinClientAvailableReadLocationsByEndpoint); - - nextLocationInfo.ThinClientReadLocations = thinClientAvailableReadLocations; - nextLocationInfo.ThinClientReadLocationByEndpoint = thinClientAvailableReadLocationsByEndpoint; - } - - if (thinClientWriteLocations != null && thinClientWriteLocations.Count() > 0) - { - nextLocationInfo.ThinClientWriteEndpointByLocation = this.GetEndpointByLocation( - thinClientWriteLocations, - out ReadOnlyCollection thinClientAvailableWriteLocations, - out ReadOnlyDictionary thinClientAvailableWriteLocationsByEndpoint); - - nextLocationInfo.ThinClientWriteLocations = thinClientAvailableWriteLocations; - nextLocationInfo.ThinClientWriteLocationByEndpoint = thinClientAvailableWriteLocationsByEndpoint; + } + + if (thinClientReadLocations != null && thinClientReadLocations.Count() > 0) + { + nextLocationInfo.ThinClientReadEndpointByLocation = this.GetEndpointByLocation( + thinClientReadLocations, + out ReadOnlyCollection thinClientAvailableReadLocations, + out ReadOnlyDictionary thinClientAvailableReadLocationsByEndpoint); + + nextLocationInfo.ThinClientReadLocations = thinClientAvailableReadLocations; + nextLocationInfo.ThinClientReadLocationByEndpoint = thinClientAvailableReadLocationsByEndpoint; + } + + if (thinClientWriteLocations != null && thinClientWriteLocations.Count() > 0) + { + nextLocationInfo.ThinClientWriteEndpointByLocation = this.GetEndpointByLocation( + thinClientWriteLocations, + out ReadOnlyCollection thinClientAvailableWriteLocations, + out ReadOnlyDictionary thinClientAvailableWriteLocationsByEndpoint); + + nextLocationInfo.ThinClientWriteLocations = thinClientAvailableWriteLocations; + nextLocationInfo.ThinClientWriteLocationByEndpoint = thinClientAvailableWriteLocationsByEndpoint; } nextLocationInfo.WriteEndpoints = this.GetPreferredAvailableEndpoints( @@ -757,22 +760,22 @@ private void UpdateLocationCache( endpointsByLocation: nextLocationInfo.AvailableReadEndpointByLocation, orderedLocations: nextLocationInfo.AvailableReadLocations, expectedAvailableOperation: OperationType.Read, - fallbackEndpoint: nextLocationInfo.WriteEndpoints[0]); - - nextLocationInfo.EffectivePreferredLocations = nextLocationInfo.PreferredLocations; - - nextLocationInfo.ThinClientWriteEndpoints = this.GetPreferredAvailableEndpoints( - endpointsByLocation: nextLocationInfo.ThinClientWriteEndpointByLocation, - orderedLocations: nextLocationInfo.ThinClientWriteLocations, - expectedAvailableOperation: OperationType.Write, - fallbackEndpoint: this.defaultEndpoint); - - nextLocationInfo.ThinClientReadEndpoints = this.GetPreferredAvailableEndpoints( - endpointsByLocation: nextLocationInfo.ThinClientReadEndpointByLocation, - orderedLocations: nextLocationInfo.ThinClientReadLocations, - expectedAvailableOperation: OperationType.Read, - fallbackEndpoint: nextLocationInfo.ThinClientWriteEndpoints[0]); - + fallbackEndpoint: nextLocationInfo.WriteEndpoints[0]); + + nextLocationInfo.EffectivePreferredLocations = nextLocationInfo.PreferredLocations; + + nextLocationInfo.ThinClientWriteEndpoints = this.GetPreferredAvailableEndpoints( + endpointsByLocation: nextLocationInfo.ThinClientWriteEndpointByLocation, + orderedLocations: nextLocationInfo.ThinClientWriteLocations, + expectedAvailableOperation: OperationType.Write, + fallbackEndpoint: this.defaultEndpoint); + + nextLocationInfo.ThinClientReadEndpoints = this.GetPreferredAvailableEndpoints( + endpointsByLocation: nextLocationInfo.ThinClientReadEndpointByLocation, + orderedLocations: nextLocationInfo.ThinClientReadLocations, + expectedAvailableOperation: OperationType.Read, + fallbackEndpoint: nextLocationInfo.ThinClientWriteEndpoints[0]); + if (nextLocationInfo.PreferredLocations == null || nextLocationInfo.PreferredLocations.Count == 0) { if (!nextLocationInfo.AvailableReadLocationByEndpoint.TryGetValue(this.defaultEndpoint, out string regionForDefaultEndpoint)) @@ -780,9 +783,9 @@ private void UpdateLocationCache( nextLocationInfo.EffectivePreferredLocations = nextLocationInfo.AvailableReadLocations; } else - { - // if defaultEndpoint equals a regional endpoint - do not use account-level regions, - // stick to defaultEndpoint configured for the CosmosClient instance + { + // if defaultEndpoint equals a regional endpoint - do not use account-level regions, + // stick to defaultEndpoint configured for the CosmosClient instance List locations = new () { regionForDefaultEndpoint @@ -841,8 +844,8 @@ private ReadOnlyCollection GetPreferredAvailableEndpoints(ReadOnlyDictionar foreach (string location in orderedLocations) { if (endpointsByLocation.TryGetValue(location, out Uri endpoint)) - { - // if defaultEndpoint equals a regional endpoint - do not use account-level regions, + { + // if defaultEndpoint equals a regional endpoint - do not use account-level regions, // stick to defaultEndpoint configured for the CosmosClient instance if (this.defaultEndpoint.Equals(endpoint)) { @@ -929,25 +932,25 @@ internal bool CanUseMultipleWriteLocations() { return this.useMultipleWriteLocations && this.enableMultipleWriteLocations; } - - internal Uri ResolveThinClientEndpoint(DocumentServiceRequest request, bool isReadRequest) - { + + internal Uri ResolveThinClientEndpoint(DocumentServiceRequest request, bool isReadRequest) + { if (request.RequestContext != null && request.RequestContext.LocationEndpointToRoute != null) { return request.RequestContext.LocationEndpointToRoute; - } - - DatabaseAccountLocationsInfo snapshot = this.locationInfo; - ReadOnlyCollection endpoints = isReadRequest - ? snapshot.ThinClientReadEndpoints - : snapshot.ThinClientWriteEndpoints; - - int locationIndex = request.RequestContext.LocationIndexToRoute.GetValueOrDefault(0); - Uri chosenEndpoint = endpoints[locationIndex % endpoints.Count]; - - request.RequestContext.RouteToLocation(chosenEndpoint); - return chosenEndpoint; - } + } + + DatabaseAccountLocationsInfo snapshot = this.locationInfo; + ReadOnlyCollection endpoints = isReadRequest + ? snapshot.ThinClientReadEndpoints + : snapshot.ThinClientWriteEndpoints; + + int locationIndex = request.RequestContext.LocationIndexToRoute.GetValueOrDefault(0); + Uri chosenEndpoint = endpoints[locationIndex % endpoints.Count]; + + request.RequestContext.RouteToLocation(chosenEndpoint); + return chosenEndpoint; + } private void SetServicePointConnectionLimit(Uri endpoint) { @@ -980,20 +983,20 @@ public DatabaseAccountLocationsInfo(ReadOnlyCollection preferredLocation this.WriteEndpoints = new List() { defaultEndpoint }.AsReadOnly(); this.AccountReadEndpoints = new List() { defaultEndpoint }.AsReadOnly(); this.ReadEndpoints = new List() { defaultEndpoint }.AsReadOnly(); - this.EffectivePreferredLocations = new List().AsReadOnly(); - - this.ThinClientWriteLocations = new List().AsReadOnly(); - this.ThinClientReadLocations = new List().AsReadOnly(); - this.ThinClientWriteEndpointByLocation = - new ReadOnlyDictionary(new Dictionary()); - this.ThinClientReadEndpointByLocation = - new ReadOnlyDictionary(new Dictionary()); - this.ThinClientWriteLocationByEndpoint = - new ReadOnlyDictionary(new Dictionary()); - this.ThinClientReadLocationByEndpoint = - new ReadOnlyDictionary(new Dictionary()); - this.ThinClientWriteEndpoints = new List() { defaultEndpoint }.AsReadOnly(); - this.ThinClientReadEndpoints = new List() { defaultEndpoint }.AsReadOnly(); + this.EffectivePreferredLocations = new List().AsReadOnly(); + + this.ThinClientWriteLocations = new List().AsReadOnly(); + this.ThinClientReadLocations = new List().AsReadOnly(); + this.ThinClientWriteEndpointByLocation = + new ReadOnlyDictionary(new Dictionary()); + this.ThinClientReadEndpointByLocation = + new ReadOnlyDictionary(new Dictionary()); + this.ThinClientWriteLocationByEndpoint = + new ReadOnlyDictionary(new Dictionary()); + this.ThinClientReadLocationByEndpoint = + new ReadOnlyDictionary(new Dictionary()); + this.ThinClientWriteEndpoints = new List() { defaultEndpoint }.AsReadOnly(); + this.ThinClientReadEndpoints = new List() { defaultEndpoint }.AsReadOnly(); } @@ -1009,15 +1012,15 @@ public DatabaseAccountLocationsInfo(DatabaseAccountLocationsInfo other) this.WriteEndpoints = other.WriteEndpoints; this.AccountReadEndpoints = other.AccountReadEndpoints; this.ReadEndpoints = other.ReadEndpoints; - this.EffectivePreferredLocations = other.EffectivePreferredLocations; - - this.ThinClientWriteLocations = other.ThinClientWriteLocations; - this.ThinClientReadLocations = other.ThinClientReadLocations; - this.ThinClientWriteEndpointByLocation = other.ThinClientWriteEndpointByLocation; - this.ThinClientReadEndpointByLocation = other.ThinClientReadEndpointByLocation; - this.ThinClientWriteLocationByEndpoint = other.ThinClientWriteLocationByEndpoint; - this.ThinClientReadLocationByEndpoint = other.ThinClientReadLocationByEndpoint; - this.ThinClientWriteEndpoints = other.ThinClientWriteEndpoints; + this.EffectivePreferredLocations = other.EffectivePreferredLocations; + + this.ThinClientWriteLocations = other.ThinClientWriteLocations; + this.ThinClientReadLocations = other.ThinClientReadLocations; + this.ThinClientWriteEndpointByLocation = other.ThinClientWriteEndpointByLocation; + this.ThinClientReadEndpointByLocation = other.ThinClientReadEndpointByLocation; + this.ThinClientWriteLocationByEndpoint = other.ThinClientWriteLocationByEndpoint; + this.ThinClientReadLocationByEndpoint = other.ThinClientReadLocationByEndpoint; + this.ThinClientWriteEndpoints = other.ThinClientWriteEndpoints; this.ThinClientReadEndpoints = other.ThinClientReadEndpoints; } @@ -1032,16 +1035,16 @@ public DatabaseAccountLocationsInfo(DatabaseAccountLocationsInfo other) public ReadOnlyCollection WriteEndpoints { get; set; } public ReadOnlyCollection ReadEndpoints { get; set; } public ReadOnlyCollection AccountReadEndpoints { get; set; } - public ReadOnlyCollection EffectivePreferredLocations { get; set; } - public ReadOnlyCollection ThinClientWriteLocations { get; set; } - public ReadOnlyDictionary ThinClientWriteEndpointByLocation { get; set; } - public ReadOnlyDictionary ThinClientWriteLocationByEndpoint { get; set; } - public ReadOnlyCollection ThinClientWriteEndpoints { get; set; } - - public ReadOnlyCollection ThinClientReadLocations { get; set; } - public ReadOnlyDictionary ThinClientReadEndpointByLocation { get; set; } - public ReadOnlyDictionary ThinClientReadLocationByEndpoint { get; set; } - public ReadOnlyCollection ThinClientReadEndpoints { get; set; } + public ReadOnlyCollection EffectivePreferredLocations { get; set; } + public ReadOnlyCollection ThinClientWriteLocations { get; set; } + public ReadOnlyDictionary ThinClientWriteEndpointByLocation { get; set; } + public ReadOnlyDictionary ThinClientWriteLocationByEndpoint { get; set; } + public ReadOnlyCollection ThinClientWriteEndpoints { get; set; } + + public ReadOnlyCollection ThinClientReadLocations { get; set; } + public ReadOnlyDictionary ThinClientReadEndpointByLocation { get; set; } + public ReadOnlyDictionary ThinClientReadLocationByEndpoint { get; set; } + public ReadOnlyCollection ThinClientReadEndpoints { get; set; } } diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs index b8f85d4bef..c6d70deef1 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs @@ -121,6 +121,26 @@ public void ValidateTryGetLocationForGatewayDiagnostics() } } + [TestMethod] + [Owner("ntripician")] + public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointForMultiMaster() + { + using GlobalEndpointManager endpointManager = this.Initialize( + useMultipleWriteLocations: true, + enableEndpointDiscovery: true, + isPreferredLocationsListEmpty: false); + + string expectedHubRegionName = this.databaseAccount.WriteLocationsInternal.First().Name; + + Assert.AreEqual(expectedHubRegionName, this.cache.GetLocation(LocationCacheTests.DefaultEndpoint)); + + Assert.AreEqual(true, this.cache.TryGetLocationForGatewayDiagnostics(LocationCacheTests.DefaultEndpoint, out string regionName)); + Assert.AreEqual(expectedHubRegionName, regionName); + + Assert.AreEqual(true, this.cache.TryGetLocationForGatewayDiagnostics(new Uri(LocationCacheTests.DefaultEndpoint, "random/path"), out regionName)); + Assert.AreEqual(expectedHubRegionName, regionName); + } + [TestMethod] [Owner("atulk")] public async Task ValidateRetryOnSessionNotAvailableWithDisableMultipleWriteLocationsAndEndpointDiscoveryDisabled() @@ -195,7 +215,7 @@ private ClientRetryPolicy CreateClientRetryPolicy( endpointManager, this.partitionKeyRangeLocationCache, new RetryOptions(), - enableEndpointDiscovery, + enableEndpointDiscovery, false); } @@ -1432,56 +1452,56 @@ public void VerifyRegionExcludedTest( } } - - [TestMethod] - public void ValidateThinClientReadFallbackToWriteEndpointTest() - { - // Arrange: - Collection normalReads = new Collection() - { - new AccountRegion { Name = "ReadLocation", Endpoint = "https://readlocation.documents.azure.com" } - }; - - Collection normalWrites = new Collection() - { - new AccountRegion { Name = "WriteLocation", Endpoint = "https://writelocation.documents.azure.com" } - }; - - Collection thinClientReads = new Collection(); // 👈 simulate NO thin client read locations - - Collection thinClientWrites = new Collection() - { - new AccountRegion { Name = "ThinClientWriteLocation", Endpoint = "https://thinclient-write.documents.azure.com:10650/" } - }; - - AccountProperties accountProps = new AccountProperties - { - ReadLocationsInternal = normalReads, - WriteLocationsInternal = normalWrites, - ThinClientReadableLocationsInternal = thinClientReads, - ThinClientWritableLocationsInternal = thinClientWrites, - EnableMultipleWriteLocations = false - }; - - LocationCache cache = new LocationCache( - preferredLocations: new ReadOnlyCollection(new List()), - defaultEndpoint: new Uri("https://defaultendpoint.documents.azure.com"), - enableEndpointDiscovery: true, - connectionLimit: 50, - useMultipleWriteLocations: false); - - cache.OnDatabaseAccountRead(accountProps); - - // Act: - using (DocumentServiceRequest readRequest = DocumentServiceRequest.Create(OperationType.Read, ResourceType.Document, AuthorizationTokenType.PrimaryMasterKey)) - { - Uri resolvedReadEndpoint = cache.ResolveThinClientEndpoint(readRequest, isReadRequest: true); - - // Assert: - Assert.AreEqual("https://thinclient-write.documents.azure.com:10650/", resolvedReadEndpoint.AbsoluteUri, - "Read request should fallback to thin client write endpoint when no thin client read endpoint is available."); - } - } + + [TestMethod] + public void ValidateThinClientReadFallbackToWriteEndpointTest() + { + // Arrange: + Collection normalReads = new Collection() + { + new AccountRegion { Name = "ReadLocation", Endpoint = "https://readlocation.documents.azure.com" } + }; + + Collection normalWrites = new Collection() + { + new AccountRegion { Name = "WriteLocation", Endpoint = "https://writelocation.documents.azure.com" } + }; + + Collection thinClientReads = new Collection(); // 👈 simulate NO thin client read locations + + Collection thinClientWrites = new Collection() + { + new AccountRegion { Name = "ThinClientWriteLocation", Endpoint = "https://thinclient-write.documents.azure.com:10650/" } + }; + + AccountProperties accountProps = new AccountProperties + { + ReadLocationsInternal = normalReads, + WriteLocationsInternal = normalWrites, + ThinClientReadableLocationsInternal = thinClientReads, + ThinClientWritableLocationsInternal = thinClientWrites, + EnableMultipleWriteLocations = false + }; + + LocationCache cache = new LocationCache( + preferredLocations: new ReadOnlyCollection(new List()), + defaultEndpoint: new Uri("https://defaultendpoint.documents.azure.com"), + enableEndpointDiscovery: true, + connectionLimit: 50, + useMultipleWriteLocations: false); + + cache.OnDatabaseAccountRead(accountProps); + + // Act: + using (DocumentServiceRequest readRequest = DocumentServiceRequest.Create(OperationType.Read, ResourceType.Document, AuthorizationTokenType.PrimaryMasterKey)) + { + Uri resolvedReadEndpoint = cache.ResolveThinClientEndpoint(readRequest, isReadRequest: true); + + // Assert: + Assert.AreEqual("https://thinclient-write.documents.azure.com:10650/", resolvedReadEndpoint.AbsoluteUri, + "Read request should fallback to thin client write endpoint when no thin client read endpoint is available."); + } + } [TestMethod] public void ValidateThinClientLocationCacheFlowTest() @@ -1726,9 +1746,9 @@ private GlobalEndpointManager Initialize( GlobalEndpointManager endpointManager = new GlobalEndpointManager(this.mockedClient.Object, connectionPolicy); this.partitionKeyRangeLocationCache = enablePartitionLevelFailover - ? new GlobalPartitionEndpointManagerCore( - endpointManager, - isPartitionLevelFailoverEnabled: true, + ? new GlobalPartitionEndpointManagerCore( + endpointManager, + isPartitionLevelFailoverEnabled: true, isPartitionLevelCircuitBreakerEnabled: true) : GlobalPartitionEndpointManagerNoOp.Instance; From 7e5cfe53cf9ceca7bcfbcaaa2c962d600450e342 Mon Sep 17 00:00:00 2001 From: Nalu Tripician <27316859+NaluTripician@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:37:33 -0700 Subject: [PATCH 2/7] Diagnostics: Fixes review comments on multimaster default endpoint resolution - Use enableMultipleWriteLocations (server-side setting) instead of CanUseMultipleWriteLocations() (client opt-in AND server) so diagnostics resolve the hub region even when the client has UseMultipleWriteLocations disabled. - Use AvailableWriteLocations[0] (ordered ReadOnlyCollection) instead of AvailableWriteEndpointByLocation.First().Key (unordered Dictionary) for deterministic region name selection. - Add test for asymmetric case: account multi-master, client opt-out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Routing/LocationCache.cs | 6 ++-- .../LocationCacheTests.cs | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs index 60d12d0747..6f430e9fab 100644 --- a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs +++ b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs @@ -174,9 +174,9 @@ public string GetLocation(Uri endpoint) if (location == null && endpoint == this.defaultEndpoint) { - if (this.locationInfo.AvailableWriteEndpointByLocation.Any()) + if (this.locationInfo.AvailableWriteLocations.Count > 0) { - return this.locationInfo.AvailableWriteEndpointByLocation.First().Key; + return this.locationInfo.AvailableWriteLocations[0]; } } @@ -200,7 +200,7 @@ public bool TryGetLocationForGatewayDiagnostics(Uri endpoint, out string regionN UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase) == 0) { - if (this.CanUseMultipleWriteLocations()) + if (this.enableMultipleWriteLocations) { regionName = this.GetLocation(this.defaultEndpoint); return regionName != null; diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs index c6d70deef1..ab5e0d6868 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs @@ -141,6 +141,34 @@ public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointForMulti Assert.AreEqual(expectedHubRegionName, regionName); } + [TestMethod] + [Owner("ntripician")] + public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointForMultiMasterWithClientOptOut() + { + // Account is multi-master but client has UseMultipleWriteLocations = false. + // Diagnostics should still resolve the default endpoint to the hub region. + using GlobalEndpointManager endpointManager = this.Initialize( + useMultipleWriteLocations: false, + enableEndpointDiscovery: true, + isPreferredLocationsListEmpty: false); + + // Override account setting to multi-master (server-side) while client did not opt in + this.databaseAccount = LocationCacheTests.CreateDatabaseAccount( + useMultipleWriteLocations: true, + enforceSingleMasterSingleWriteLocation: false); + this.cache.OnDatabaseAccountRead(this.databaseAccount); + + string expectedHubRegionName = this.databaseAccount.WriteLocationsInternal.First().Name; + + Assert.AreEqual(expectedHubRegionName, this.cache.GetLocation(LocationCacheTests.DefaultEndpoint)); + + Assert.AreEqual(true, this.cache.TryGetLocationForGatewayDiagnostics(LocationCacheTests.DefaultEndpoint, out string regionName)); + Assert.AreEqual(expectedHubRegionName, regionName); + + Assert.AreEqual(true, this.cache.TryGetLocationForGatewayDiagnostics(new Uri(LocationCacheTests.DefaultEndpoint, "random/path"), out regionName)); + Assert.AreEqual(expectedHubRegionName, regionName); + } + [TestMethod] [Owner("atulk")] public async Task ValidateRetryOnSessionNotAvailableWithDisableMultipleWriteLocationsAndEndpointDiscoveryDisabled() From cd357e33cd4965de31ab8993eae7261d60406d06 Mon Sep 17 00:00:00 2001 From: Nalu Tripician <27316859+NaluTripician@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:28:59 -0700 Subject: [PATCH 3/7] Diagnostics: Adds clarifying comment and edge case tests for multimaster diagnostics - Adds inline comment explaining why enableMultipleWriteLocations (account-level) is used instead of CanUseMultipleWriteLocations() (requires client opt-in) in TryGetLocationForGatewayDiagnostics, since diagnostics should resolve the hub region regardless of client multi-write configuration. - Adds test for unknown/unresolvable non-default endpoint (validates the return regionName != null fix returns false for unknown endpoints). - Adds test for pre-OnDatabaseAccountRead state (validates correct behavior when AvailableWriteLocations is empty and enableMultipleWriteLocations defaults to false). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Routing/LocationCache.cs | 3 ++ .../LocationCacheTests.cs | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs index 6f430e9fab..1133efb808 100644 --- a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs +++ b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs @@ -200,6 +200,9 @@ public bool TryGetLocationForGatewayDiagnostics(Uri endpoint, out string regionN UriFormat.SafeUnescaped, StringComparison.OrdinalIgnoreCase) == 0) { + // Use account-level enableMultipleWriteLocations (not CanUseMultipleWriteLocations which also + // requires client opt-in) because diagnostics should resolve the hub region regardless of whether + // the client uses multi-write. The default endpoint routes to the hub/write region server-side. if (this.enableMultipleWriteLocations) { regionName = this.GetLocation(this.defaultEndpoint); diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs index ab5e0d6868..ef3cea4559 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs @@ -169,6 +169,47 @@ public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointForMulti Assert.AreEqual(expectedHubRegionName, regionName); } + [TestMethod] + [Owner("ntripician")] + public void ValidateTryGetLocationForGatewayDiagnosticsReturnsFalseForUnknownEndpoint() + { + using GlobalEndpointManager endpointManager = this.Initialize( + useMultipleWriteLocations: true, + enableEndpointDiscovery: true, + isPreferredLocationsListEmpty: false); + + // An endpoint that is neither the default endpoint nor any known regional endpoint + Uri unknownEndpoint = new Uri("https://unknown-region.documents.azure.com"); + + Assert.IsNull(this.cache.GetLocation(unknownEndpoint)); + + Assert.AreEqual(false, this.cache.TryGetLocationForGatewayDiagnostics(unknownEndpoint, out string regionName)); + Assert.IsNull(regionName); + } + + [TestMethod] + [Owner("ntripician")] + public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointBeforeAccountRead() + { + // Simulate multimaster cache before any account info is populated. + // AvailableWriteLocations will be empty, so GetLocation should return null. + LocationCache uninitializedCache = new LocationCache( + preferredLocations: new ReadOnlyCollection(new List { "location1" }), + defaultEndpoint: LocationCacheTests.DefaultEndpoint, + enableEndpointDiscovery: true, + connectionLimit: 50, + useMultipleWriteLocations: true); + + // No OnDatabaseAccountRead called, so AvailableWriteLocations is empty + Assert.IsNull(uninitializedCache.GetLocation(LocationCacheTests.DefaultEndpoint)); + + // enableMultipleWriteLocations defaults to false until OnDatabaseAccountRead is called + // with a multi-master account, so TryGetLocationForGatewayDiagnostics falls through to + // the single-master path and returns false + Assert.AreEqual(false, uninitializedCache.TryGetLocationForGatewayDiagnostics(LocationCacheTests.DefaultEndpoint, out string regionName)); + Assert.IsNull(regionName); + } + [TestMethod] [Owner("atulk")] public async Task ValidateRetryOnSessionNotAvailableWithDisableMultipleWriteLocationsAndEndpointDiscoveryDisabled() From d63f4d48fc9688043f1cdc0c8308084182769a77 Mon Sep 17 00:00:00 2001 From: Nalu Tripician <27316859+NaluTripician@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:11:44 -0700 Subject: [PATCH 4/7] Address review feedback: use Any(), collapse if, remove whitespace noise - Use .Any() instead of .Count > 0 for AvailableWriteLocations guard (per Kiran) - Collapse inner enableMultipleWriteLocations check into ternary (per Kiran) - Reset files to master baseline to eliminate all CRLF/whitespace-only diffs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Routing/LocationCache.cs | 276 +++++++++--------- .../LocationCacheTests.cs | 108 +++---- 2 files changed, 190 insertions(+), 194 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs index 1133efb808..84322e1022 100644 --- a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs +++ b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs @@ -124,7 +124,7 @@ public ReadOnlyCollection WriteEndpoints return this.locationInfo.WriteEndpoints; } } - + /// /// Gets the list of thin client read endpoints. /// @@ -141,7 +141,7 @@ public ReadOnlyCollection ThinClientReadEndpoints return this.locationInfo.ThinClientReadEndpoints; } - } + } /// /// Gets the list of thin client write endpoints. @@ -159,7 +159,7 @@ public ReadOnlyCollection ThinClientWriteEndpoints return this.locationInfo.ThinClientWriteEndpoints; } - } + } public ReadOnlyCollection EffectivePreferredLocations => this.locationInfo.EffectivePreferredLocations; @@ -174,7 +174,7 @@ public string GetLocation(Uri endpoint) if (location == null && endpoint == this.defaultEndpoint) { - if (this.locationInfo.AvailableWriteLocations.Count > 0) + if (this.locationInfo.AvailableWriteLocations.Any()) { return this.locationInfo.AvailableWriteLocations[0]; } @@ -203,14 +203,10 @@ public bool TryGetLocationForGatewayDiagnostics(Uri endpoint, out string regionN // Use account-level enableMultipleWriteLocations (not CanUseMultipleWriteLocations which also // requires client opt-in) because diagnostics should resolve the hub region regardless of whether // the client uses multi-write. The default endpoint routes to the hub/write region server-side. - if (this.enableMultipleWriteLocations) - { - regionName = this.GetLocation(this.defaultEndpoint); - return regionName != null; - } - - regionName = null; - return false; + regionName = this.enableMultipleWriteLocations + ? this.GetLocation(this.defaultEndpoint) + : null; + return regionName != null; } regionName = this.GetLocation(endpoint); @@ -241,9 +237,9 @@ public void OnDatabaseAccountRead(AccountProperties databaseAccount) { this.UpdateLocationCache( databaseAccount.WritableRegions, - databaseAccount.ReadableRegions, - thinClientWriteLocations: databaseAccount.ThinClientWritableLocationsInternal, - thinClientReadLocations: databaseAccount.ThinClientReadableLocationsInternal, + databaseAccount.ReadableRegions, + thinClientWriteLocations: databaseAccount.ThinClientWritableLocationsInternal, + thinClientReadLocations: databaseAccount.ThinClientReadableLocationsInternal, preferenceList: null, enableMultipleWriteLocations: databaseAccount.EnableMultipleWriteLocations); } @@ -319,7 +315,7 @@ public ReadOnlyCollection GetAvailableAccountLevelReadLocations() public ReadOnlyCollection GetAvailableAccountLevelWriteLocations() { return this.locationInfo.AvailableWriteLocations; - } + } /// /// Resolves request to service endpoint. @@ -397,17 +393,17 @@ public ReadOnlyCollection GetApplicableRegions(IEnumerable exclu { DatabaseAccountLocationsInfo databaseAccountLocationsInfoSnapshot = this.locationInfo; - ReadOnlyCollection effectivePreferredLocations = this.locationInfo.EffectivePreferredLocations; - - if (effectivePreferredLocations == null || effectivePreferredLocations.Count == 0) - { - throw new ArgumentException("effectivePreferredLocations cannot be null or empty!"); + ReadOnlyCollection effectivePreferredLocations = this.locationInfo.EffectivePreferredLocations; + + if (effectivePreferredLocations == null || effectivePreferredLocations.Count == 0) + { + throw new ArgumentException("effectivePreferredLocations cannot be null or empty!"); } - - return GetApplicableRegions( - isReadRequest ? databaseAccountLocationsInfoSnapshot.AvailableReadLocations : databaseAccountLocationsInfoSnapshot.AvailableWriteLocations, - effectivePreferredLocations, - effectivePreferredLocations[0], + + return GetApplicableRegions( + isReadRequest ? databaseAccountLocationsInfoSnapshot.AvailableReadLocations : databaseAccountLocationsInfoSnapshot.AvailableWriteLocations, + effectivePreferredLocations, + effectivePreferredLocations[0], excludeRegions); } @@ -426,19 +422,19 @@ private static ReadOnlyCollection GetApplicableEndpoints( IEnumerable excludeRegions) { List applicableEndpoints = new List(regionNameByEndpoint.Count); - HashSet excludeRegionsHash = excludeRegions == null ? new HashSet() : new HashSet(excludeRegions); - - foreach (string region in effectivePreferredLocations) - { - if (excludeRegionsHash.Count > 0) - { - if (!excludeRegionsHash.Contains(region) && regionNameByEndpoint.TryGetValue(region, out Uri endpoint)) - { - applicableEndpoints.Add(endpoint); - } - } + HashSet excludeRegionsHash = excludeRegions == null ? new HashSet() : new HashSet(excludeRegions); + + foreach (string region in effectivePreferredLocations) + { + if (excludeRegionsHash.Count > 0) + { + if (!excludeRegionsHash.Contains(region) && regionNameByEndpoint.TryGetValue(region, out Uri endpoint)) + { + applicableEndpoints.Add(endpoint); + } + } else - { + { if (regionNameByEndpoint.TryGetValue(region, out Uri endpoint)) { applicableEndpoints.Add(endpoint); @@ -599,8 +595,8 @@ public bool CanUseMultipleWriteLocations(DocumentServiceRequest request) return this.CanUseMultipleWriteLocations() && (request.ResourceType == ResourceType.Document || (request.ResourceType == ResourceType.StoredProcedure && request.OperationType == Documents.OperationType.ExecuteJavaScript)); - } - + } + private void ClearStaleEndpointUnavailabilityInfo() { if (this.locationUnavailablityInfoByEndpoint.Any()) @@ -682,14 +678,14 @@ private void MarkEndpointUnavailable( unavailableEndpoint, unavailableOperationType, updatedInfo.LastUnavailabilityCheckTimeStamp); - } - - private void UpdateLocationCache( - IEnumerable writeLocations = null, - IEnumerable readLocations = null, - IEnumerable thinClientWriteLocations = null, - IEnumerable thinClientReadLocations = null, - ReadOnlyCollection preferenceList = null, + } + + private void UpdateLocationCache( + IEnumerable writeLocations = null, + IEnumerable readLocations = null, + IEnumerable thinClientWriteLocations = null, + IEnumerable thinClientReadLocations = null, + ReadOnlyCollection preferenceList = null, bool? enableMultipleWriteLocations = null) { lock (this.lockObject) @@ -729,28 +725,28 @@ private void UpdateLocationCache( nextLocationInfo.AvailableWriteLocations = availableWriteLocations; nextLocationInfo.AvailableWriteLocationByEndpoint = availableWriteLocationsByEndpoint; - } - - if (thinClientReadLocations != null && thinClientReadLocations.Count() > 0) - { - nextLocationInfo.ThinClientReadEndpointByLocation = this.GetEndpointByLocation( - thinClientReadLocations, - out ReadOnlyCollection thinClientAvailableReadLocations, - out ReadOnlyDictionary thinClientAvailableReadLocationsByEndpoint); - - nextLocationInfo.ThinClientReadLocations = thinClientAvailableReadLocations; - nextLocationInfo.ThinClientReadLocationByEndpoint = thinClientAvailableReadLocationsByEndpoint; - } - - if (thinClientWriteLocations != null && thinClientWriteLocations.Count() > 0) - { - nextLocationInfo.ThinClientWriteEndpointByLocation = this.GetEndpointByLocation( - thinClientWriteLocations, - out ReadOnlyCollection thinClientAvailableWriteLocations, - out ReadOnlyDictionary thinClientAvailableWriteLocationsByEndpoint); - - nextLocationInfo.ThinClientWriteLocations = thinClientAvailableWriteLocations; - nextLocationInfo.ThinClientWriteLocationByEndpoint = thinClientAvailableWriteLocationsByEndpoint; + } + + if (thinClientReadLocations != null && thinClientReadLocations.Count() > 0) + { + nextLocationInfo.ThinClientReadEndpointByLocation = this.GetEndpointByLocation( + thinClientReadLocations, + out ReadOnlyCollection thinClientAvailableReadLocations, + out ReadOnlyDictionary thinClientAvailableReadLocationsByEndpoint); + + nextLocationInfo.ThinClientReadLocations = thinClientAvailableReadLocations; + nextLocationInfo.ThinClientReadLocationByEndpoint = thinClientAvailableReadLocationsByEndpoint; + } + + if (thinClientWriteLocations != null && thinClientWriteLocations.Count() > 0) + { + nextLocationInfo.ThinClientWriteEndpointByLocation = this.GetEndpointByLocation( + thinClientWriteLocations, + out ReadOnlyCollection thinClientAvailableWriteLocations, + out ReadOnlyDictionary thinClientAvailableWriteLocationsByEndpoint); + + nextLocationInfo.ThinClientWriteLocations = thinClientAvailableWriteLocations; + nextLocationInfo.ThinClientWriteLocationByEndpoint = thinClientAvailableWriteLocationsByEndpoint; } nextLocationInfo.WriteEndpoints = this.GetPreferredAvailableEndpoints( @@ -763,22 +759,22 @@ private void UpdateLocationCache( endpointsByLocation: nextLocationInfo.AvailableReadEndpointByLocation, orderedLocations: nextLocationInfo.AvailableReadLocations, expectedAvailableOperation: OperationType.Read, - fallbackEndpoint: nextLocationInfo.WriteEndpoints[0]); - - nextLocationInfo.EffectivePreferredLocations = nextLocationInfo.PreferredLocations; - - nextLocationInfo.ThinClientWriteEndpoints = this.GetPreferredAvailableEndpoints( - endpointsByLocation: nextLocationInfo.ThinClientWriteEndpointByLocation, - orderedLocations: nextLocationInfo.ThinClientWriteLocations, - expectedAvailableOperation: OperationType.Write, - fallbackEndpoint: this.defaultEndpoint); - - nextLocationInfo.ThinClientReadEndpoints = this.GetPreferredAvailableEndpoints( - endpointsByLocation: nextLocationInfo.ThinClientReadEndpointByLocation, - orderedLocations: nextLocationInfo.ThinClientReadLocations, - expectedAvailableOperation: OperationType.Read, - fallbackEndpoint: nextLocationInfo.ThinClientWriteEndpoints[0]); - + fallbackEndpoint: nextLocationInfo.WriteEndpoints[0]); + + nextLocationInfo.EffectivePreferredLocations = nextLocationInfo.PreferredLocations; + + nextLocationInfo.ThinClientWriteEndpoints = this.GetPreferredAvailableEndpoints( + endpointsByLocation: nextLocationInfo.ThinClientWriteEndpointByLocation, + orderedLocations: nextLocationInfo.ThinClientWriteLocations, + expectedAvailableOperation: OperationType.Write, + fallbackEndpoint: this.defaultEndpoint); + + nextLocationInfo.ThinClientReadEndpoints = this.GetPreferredAvailableEndpoints( + endpointsByLocation: nextLocationInfo.ThinClientReadEndpointByLocation, + orderedLocations: nextLocationInfo.ThinClientReadLocations, + expectedAvailableOperation: OperationType.Read, + fallbackEndpoint: nextLocationInfo.ThinClientWriteEndpoints[0]); + if (nextLocationInfo.PreferredLocations == null || nextLocationInfo.PreferredLocations.Count == 0) { if (!nextLocationInfo.AvailableReadLocationByEndpoint.TryGetValue(this.defaultEndpoint, out string regionForDefaultEndpoint)) @@ -786,9 +782,9 @@ private void UpdateLocationCache( nextLocationInfo.EffectivePreferredLocations = nextLocationInfo.AvailableReadLocations; } else - { - // if defaultEndpoint equals a regional endpoint - do not use account-level regions, - // stick to defaultEndpoint configured for the CosmosClient instance + { + // if defaultEndpoint equals a regional endpoint - do not use account-level regions, + // stick to defaultEndpoint configured for the CosmosClient instance List locations = new () { regionForDefaultEndpoint @@ -847,8 +843,8 @@ private ReadOnlyCollection GetPreferredAvailableEndpoints(ReadOnlyDictionar foreach (string location in orderedLocations) { if (endpointsByLocation.TryGetValue(location, out Uri endpoint)) - { - // if defaultEndpoint equals a regional endpoint - do not use account-level regions, + { + // if defaultEndpoint equals a regional endpoint - do not use account-level regions, // stick to defaultEndpoint configured for the CosmosClient instance if (this.defaultEndpoint.Equals(endpoint)) { @@ -935,25 +931,25 @@ internal bool CanUseMultipleWriteLocations() { return this.useMultipleWriteLocations && this.enableMultipleWriteLocations; } - - internal Uri ResolveThinClientEndpoint(DocumentServiceRequest request, bool isReadRequest) - { + + internal Uri ResolveThinClientEndpoint(DocumentServiceRequest request, bool isReadRequest) + { if (request.RequestContext != null && request.RequestContext.LocationEndpointToRoute != null) { return request.RequestContext.LocationEndpointToRoute; - } - - DatabaseAccountLocationsInfo snapshot = this.locationInfo; - ReadOnlyCollection endpoints = isReadRequest - ? snapshot.ThinClientReadEndpoints - : snapshot.ThinClientWriteEndpoints; - - int locationIndex = request.RequestContext.LocationIndexToRoute.GetValueOrDefault(0); - Uri chosenEndpoint = endpoints[locationIndex % endpoints.Count]; - - request.RequestContext.RouteToLocation(chosenEndpoint); - return chosenEndpoint; - } + } + + DatabaseAccountLocationsInfo snapshot = this.locationInfo; + ReadOnlyCollection endpoints = isReadRequest + ? snapshot.ThinClientReadEndpoints + : snapshot.ThinClientWriteEndpoints; + + int locationIndex = request.RequestContext.LocationIndexToRoute.GetValueOrDefault(0); + Uri chosenEndpoint = endpoints[locationIndex % endpoints.Count]; + + request.RequestContext.RouteToLocation(chosenEndpoint); + return chosenEndpoint; + } private void SetServicePointConnectionLimit(Uri endpoint) { @@ -986,20 +982,20 @@ public DatabaseAccountLocationsInfo(ReadOnlyCollection preferredLocation this.WriteEndpoints = new List() { defaultEndpoint }.AsReadOnly(); this.AccountReadEndpoints = new List() { defaultEndpoint }.AsReadOnly(); this.ReadEndpoints = new List() { defaultEndpoint }.AsReadOnly(); - this.EffectivePreferredLocations = new List().AsReadOnly(); - - this.ThinClientWriteLocations = new List().AsReadOnly(); - this.ThinClientReadLocations = new List().AsReadOnly(); - this.ThinClientWriteEndpointByLocation = - new ReadOnlyDictionary(new Dictionary()); - this.ThinClientReadEndpointByLocation = - new ReadOnlyDictionary(new Dictionary()); - this.ThinClientWriteLocationByEndpoint = - new ReadOnlyDictionary(new Dictionary()); - this.ThinClientReadLocationByEndpoint = - new ReadOnlyDictionary(new Dictionary()); - this.ThinClientWriteEndpoints = new List() { defaultEndpoint }.AsReadOnly(); - this.ThinClientReadEndpoints = new List() { defaultEndpoint }.AsReadOnly(); + this.EffectivePreferredLocations = new List().AsReadOnly(); + + this.ThinClientWriteLocations = new List().AsReadOnly(); + this.ThinClientReadLocations = new List().AsReadOnly(); + this.ThinClientWriteEndpointByLocation = + new ReadOnlyDictionary(new Dictionary()); + this.ThinClientReadEndpointByLocation = + new ReadOnlyDictionary(new Dictionary()); + this.ThinClientWriteLocationByEndpoint = + new ReadOnlyDictionary(new Dictionary()); + this.ThinClientReadLocationByEndpoint = + new ReadOnlyDictionary(new Dictionary()); + this.ThinClientWriteEndpoints = new List() { defaultEndpoint }.AsReadOnly(); + this.ThinClientReadEndpoints = new List() { defaultEndpoint }.AsReadOnly(); } @@ -1015,15 +1011,15 @@ public DatabaseAccountLocationsInfo(DatabaseAccountLocationsInfo other) this.WriteEndpoints = other.WriteEndpoints; this.AccountReadEndpoints = other.AccountReadEndpoints; this.ReadEndpoints = other.ReadEndpoints; - this.EffectivePreferredLocations = other.EffectivePreferredLocations; - - this.ThinClientWriteLocations = other.ThinClientWriteLocations; - this.ThinClientReadLocations = other.ThinClientReadLocations; - this.ThinClientWriteEndpointByLocation = other.ThinClientWriteEndpointByLocation; - this.ThinClientReadEndpointByLocation = other.ThinClientReadEndpointByLocation; - this.ThinClientWriteLocationByEndpoint = other.ThinClientWriteLocationByEndpoint; - this.ThinClientReadLocationByEndpoint = other.ThinClientReadLocationByEndpoint; - this.ThinClientWriteEndpoints = other.ThinClientWriteEndpoints; + this.EffectivePreferredLocations = other.EffectivePreferredLocations; + + this.ThinClientWriteLocations = other.ThinClientWriteLocations; + this.ThinClientReadLocations = other.ThinClientReadLocations; + this.ThinClientWriteEndpointByLocation = other.ThinClientWriteEndpointByLocation; + this.ThinClientReadEndpointByLocation = other.ThinClientReadEndpointByLocation; + this.ThinClientWriteLocationByEndpoint = other.ThinClientWriteLocationByEndpoint; + this.ThinClientReadLocationByEndpoint = other.ThinClientReadLocationByEndpoint; + this.ThinClientWriteEndpoints = other.ThinClientWriteEndpoints; this.ThinClientReadEndpoints = other.ThinClientReadEndpoints; } @@ -1038,16 +1034,16 @@ public DatabaseAccountLocationsInfo(DatabaseAccountLocationsInfo other) public ReadOnlyCollection WriteEndpoints { get; set; } public ReadOnlyCollection ReadEndpoints { get; set; } public ReadOnlyCollection AccountReadEndpoints { get; set; } - public ReadOnlyCollection EffectivePreferredLocations { get; set; } - public ReadOnlyCollection ThinClientWriteLocations { get; set; } - public ReadOnlyDictionary ThinClientWriteEndpointByLocation { get; set; } - public ReadOnlyDictionary ThinClientWriteLocationByEndpoint { get; set; } - public ReadOnlyCollection ThinClientWriteEndpoints { get; set; } - - public ReadOnlyCollection ThinClientReadLocations { get; set; } - public ReadOnlyDictionary ThinClientReadEndpointByLocation { get; set; } - public ReadOnlyDictionary ThinClientReadLocationByEndpoint { get; set; } - public ReadOnlyCollection ThinClientReadEndpoints { get; set; } + public ReadOnlyCollection EffectivePreferredLocations { get; set; } + public ReadOnlyCollection ThinClientWriteLocations { get; set; } + public ReadOnlyDictionary ThinClientWriteEndpointByLocation { get; set; } + public ReadOnlyDictionary ThinClientWriteLocationByEndpoint { get; set; } + public ReadOnlyCollection ThinClientWriteEndpoints { get; set; } + + public ReadOnlyCollection ThinClientReadLocations { get; set; } + public ReadOnlyDictionary ThinClientReadEndpointByLocation { get; set; } + public ReadOnlyDictionary ThinClientReadLocationByEndpoint { get; set; } + public ReadOnlyCollection ThinClientReadEndpoints { get; set; } } diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs index ef3cea4559..ad98b46506 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs @@ -284,7 +284,7 @@ private ClientRetryPolicy CreateClientRetryPolicy( endpointManager, this.partitionKeyRangeLocationCache, new RetryOptions(), - enableEndpointDiscovery, + enableEndpointDiscovery, false); } @@ -1521,56 +1521,56 @@ public void VerifyRegionExcludedTest( } } - - [TestMethod] - public void ValidateThinClientReadFallbackToWriteEndpointTest() - { - // Arrange: - Collection normalReads = new Collection() - { - new AccountRegion { Name = "ReadLocation", Endpoint = "https://readlocation.documents.azure.com" } - }; - - Collection normalWrites = new Collection() - { - new AccountRegion { Name = "WriteLocation", Endpoint = "https://writelocation.documents.azure.com" } - }; - - Collection thinClientReads = new Collection(); // 👈 simulate NO thin client read locations - - Collection thinClientWrites = new Collection() - { - new AccountRegion { Name = "ThinClientWriteLocation", Endpoint = "https://thinclient-write.documents.azure.com:10650/" } - }; - - AccountProperties accountProps = new AccountProperties - { - ReadLocationsInternal = normalReads, - WriteLocationsInternal = normalWrites, - ThinClientReadableLocationsInternal = thinClientReads, - ThinClientWritableLocationsInternal = thinClientWrites, - EnableMultipleWriteLocations = false - }; - - LocationCache cache = new LocationCache( - preferredLocations: new ReadOnlyCollection(new List()), - defaultEndpoint: new Uri("https://defaultendpoint.documents.azure.com"), - enableEndpointDiscovery: true, - connectionLimit: 50, - useMultipleWriteLocations: false); - - cache.OnDatabaseAccountRead(accountProps); - - // Act: - using (DocumentServiceRequest readRequest = DocumentServiceRequest.Create(OperationType.Read, ResourceType.Document, AuthorizationTokenType.PrimaryMasterKey)) - { - Uri resolvedReadEndpoint = cache.ResolveThinClientEndpoint(readRequest, isReadRequest: true); - - // Assert: - Assert.AreEqual("https://thinclient-write.documents.azure.com:10650/", resolvedReadEndpoint.AbsoluteUri, - "Read request should fallback to thin client write endpoint when no thin client read endpoint is available."); - } - } + + [TestMethod] + public void ValidateThinClientReadFallbackToWriteEndpointTest() + { + // Arrange: + Collection normalReads = new Collection() + { + new AccountRegion { Name = "ReadLocation", Endpoint = "https://readlocation.documents.azure.com" } + }; + + Collection normalWrites = new Collection() + { + new AccountRegion { Name = "WriteLocation", Endpoint = "https://writelocation.documents.azure.com" } + }; + + Collection thinClientReads = new Collection(); // 👈 simulate NO thin client read locations + + Collection thinClientWrites = new Collection() + { + new AccountRegion { Name = "ThinClientWriteLocation", Endpoint = "https://thinclient-write.documents.azure.com:10650/" } + }; + + AccountProperties accountProps = new AccountProperties + { + ReadLocationsInternal = normalReads, + WriteLocationsInternal = normalWrites, + ThinClientReadableLocationsInternal = thinClientReads, + ThinClientWritableLocationsInternal = thinClientWrites, + EnableMultipleWriteLocations = false + }; + + LocationCache cache = new LocationCache( + preferredLocations: new ReadOnlyCollection(new List()), + defaultEndpoint: new Uri("https://defaultendpoint.documents.azure.com"), + enableEndpointDiscovery: true, + connectionLimit: 50, + useMultipleWriteLocations: false); + + cache.OnDatabaseAccountRead(accountProps); + + // Act: + using (DocumentServiceRequest readRequest = DocumentServiceRequest.Create(OperationType.Read, ResourceType.Document, AuthorizationTokenType.PrimaryMasterKey)) + { + Uri resolvedReadEndpoint = cache.ResolveThinClientEndpoint(readRequest, isReadRequest: true); + + // Assert: + Assert.AreEqual("https://thinclient-write.documents.azure.com:10650/", resolvedReadEndpoint.AbsoluteUri, + "Read request should fallback to thin client write endpoint when no thin client read endpoint is available."); + } + } [TestMethod] public void ValidateThinClientLocationCacheFlowTest() @@ -1815,9 +1815,9 @@ private GlobalEndpointManager Initialize( GlobalEndpointManager endpointManager = new GlobalEndpointManager(this.mockedClient.Object, connectionPolicy); this.partitionKeyRangeLocationCache = enablePartitionLevelFailover - ? new GlobalPartitionEndpointManagerCore( - endpointManager, - isPartitionLevelFailoverEnabled: true, + ? new GlobalPartitionEndpointManagerCore( + endpointManager, + isPartitionLevelFailoverEnabled: true, isPartitionLevelCircuitBreakerEnabled: true) : GlobalPartitionEndpointManagerNoOp.Instance; From 04e03eb03be77132ee9b4a5ccdf0fcb8f3b5b273 Mon Sep 17 00:00:00 2001 From: Nalu Tripician <27316859+NaluTripician@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:30:46 -0700 Subject: [PATCH 5/7] Address Kiran's review feedback - Collapse nested if in GetLocation into single condition - Revert defensive 'return regionName != null' to original 'return true' for non-default endpoint path (unrelated to fix) - Replace 'hub' terminology with 'first available write region' in comments and test variables (MM first write region is not necessarily the hub) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/Routing/LocationCache.cs | 17 ++++++++--------- .../LocationCacheTests.cs | 18 +++++++++--------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs index 84322e1022..d2a50f8285 100644 --- a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs +++ b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs @@ -172,12 +172,11 @@ public string GetLocation(Uri endpoint) { string location = this.locationInfo.AvailableWriteEndpointByLocation.FirstOrDefault(uri => uri.Value == endpoint).Key ?? this.locationInfo.AvailableReadEndpointByLocation.FirstOrDefault(uri => uri.Value == endpoint).Key; - if (location == null && endpoint == this.defaultEndpoint) + if (location == null + && endpoint == this.defaultEndpoint + && this.locationInfo.AvailableWriteLocations.Count > 0) { - if (this.locationInfo.AvailableWriteLocations.Any()) - { - return this.locationInfo.AvailableWriteLocations[0]; - } + return this.locationInfo.AvailableWriteLocations[0]; } return location; @@ -186,7 +185,7 @@ public string GetLocation(Uri endpoint) /// /// Set region name for a location if present in the locationcache otherwise set region name as null. /// For multi-master accounts, if endpoint's hostname is same as default endpoint hostname, - /// set regionName to the write hub region. + /// set regionName to the first available write region. /// /// /// @@ -201,8 +200,8 @@ public bool TryGetLocationForGatewayDiagnostics(Uri endpoint, out string regionN StringComparison.OrdinalIgnoreCase) == 0) { // Use account-level enableMultipleWriteLocations (not CanUseMultipleWriteLocations which also - // requires client opt-in) because diagnostics should resolve the hub region regardless of whether - // the client uses multi-write. The default endpoint routes to the hub/write region server-side. + // requires client opt-in) because diagnostics should resolve the region regardless of whether + // the client uses multi-write. The default endpoint routes to the first write region server-side. regionName = this.enableMultipleWriteLocations ? this.GetLocation(this.defaultEndpoint) : null; @@ -210,7 +209,7 @@ public bool TryGetLocationForGatewayDiagnostics(Uri endpoint, out string regionN } regionName = this.GetLocation(endpoint); - return regionName != null; + return true; } /// diff --git a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs index ad98b46506..ff46dcee93 100644 --- a/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs +++ b/Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.Tests/LocationCacheTests.cs @@ -130,15 +130,15 @@ public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointForMulti enableEndpointDiscovery: true, isPreferredLocationsListEmpty: false); - string expectedHubRegionName = this.databaseAccount.WriteLocationsInternal.First().Name; + string expectedRegionName = this.databaseAccount.WriteLocationsInternal.First().Name; - Assert.AreEqual(expectedHubRegionName, this.cache.GetLocation(LocationCacheTests.DefaultEndpoint)); + Assert.AreEqual(expectedRegionName, this.cache.GetLocation(LocationCacheTests.DefaultEndpoint)); Assert.AreEqual(true, this.cache.TryGetLocationForGatewayDiagnostics(LocationCacheTests.DefaultEndpoint, out string regionName)); - Assert.AreEqual(expectedHubRegionName, regionName); + Assert.AreEqual(expectedRegionName, regionName); Assert.AreEqual(true, this.cache.TryGetLocationForGatewayDiagnostics(new Uri(LocationCacheTests.DefaultEndpoint, "random/path"), out regionName)); - Assert.AreEqual(expectedHubRegionName, regionName); + Assert.AreEqual(expectedRegionName, regionName); } [TestMethod] @@ -146,7 +146,7 @@ public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointForMulti public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointForMultiMasterWithClientOptOut() { // Account is multi-master but client has UseMultipleWriteLocations = false. - // Diagnostics should still resolve the default endpoint to the hub region. + // Diagnostics should still resolve the default endpoint to the first write region. using GlobalEndpointManager endpointManager = this.Initialize( useMultipleWriteLocations: false, enableEndpointDiscovery: true, @@ -158,15 +158,15 @@ public void ValidateTryGetLocationForGatewayDiagnosticsOnDefaultEndpointForMulti enforceSingleMasterSingleWriteLocation: false); this.cache.OnDatabaseAccountRead(this.databaseAccount); - string expectedHubRegionName = this.databaseAccount.WriteLocationsInternal.First().Name; + string expectedRegionName = this.databaseAccount.WriteLocationsInternal.First().Name; - Assert.AreEqual(expectedHubRegionName, this.cache.GetLocation(LocationCacheTests.DefaultEndpoint)); + Assert.AreEqual(expectedRegionName, this.cache.GetLocation(LocationCacheTests.DefaultEndpoint)); Assert.AreEqual(true, this.cache.TryGetLocationForGatewayDiagnostics(LocationCacheTests.DefaultEndpoint, out string regionName)); - Assert.AreEqual(expectedHubRegionName, regionName); + Assert.AreEqual(expectedRegionName, regionName); Assert.AreEqual(true, this.cache.TryGetLocationForGatewayDiagnostics(new Uri(LocationCacheTests.DefaultEndpoint, "random/path"), out regionName)); - Assert.AreEqual(expectedHubRegionName, regionName); + Assert.AreEqual(expectedRegionName, regionName); } [TestMethod] From adbb741543163659d48585f88d1ed9537af4db5c Mon Sep 17 00:00:00 2001 From: Nalu Tripician <27316859+NaluTripician@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:50:22 -0700 Subject: [PATCH 6/7] Fix TryGetLocationForGatewayDiagnostics returning true for unknown endpoints The fallthrough path returned 'true' unconditionally after calling GetLocation, even when GetLocation returned null for unrecognized endpoints. Changed to 'return regionName != null' to match the contract and the default-endpoint branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs index d2a50f8285..08485e15bb 100644 --- a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs +++ b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs @@ -209,7 +209,7 @@ public bool TryGetLocationForGatewayDiagnostics(Uri endpoint, out string regionN } regionName = this.GetLocation(endpoint); - return true; + return regionName != null; } /// From 591c926d3bd39a885ae78d6340db9da776e82353 Mon Sep 17 00:00:00 2001 From: Nalu Tripician <27316859+NaluTripician@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:10:40 -0700 Subject: [PATCH 7/7] Routing: Refactors GetLocation XML doc to reflect post-PR behavior Addresses kushagraThapar review feedback on PR #5618: the existing summary on GetLocation was stale after removing the !CanUseMultipleWriteLocations() guard. Updated doc to: (1) cover the broader write+read regional endpoint case, (2) note that the default-endpoint fallback now applies to both single-master and multi-master, and (3) clarify that the returned write location is just the first in the list, not necessarily the hub region (per his line-179 note). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs index 08485e15bb..a0fed64c14 100644 --- a/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs +++ b/Microsoft.Azure.Cosmos/src/Routing/LocationCache.cs @@ -164,9 +164,14 @@ public ReadOnlyCollection ThinClientWriteEndpoints public ReadOnlyCollection EffectivePreferredLocations => this.locationInfo.EffectivePreferredLocations; /// - /// Returns the location corresponding to the endpoint if location specific endpoint is provided. - /// For the defaultEndPoint, we will return the first available write location. - /// Returns null, in other cases. + /// Returns the region name corresponding to the given endpoint. + /// - If the endpoint matches a known write or read regional endpoint, returns that region name. + /// - If the endpoint is the account's default (global) endpoint and at least one write + /// location is known, returns the first entry of the available write locations list. + /// This applies to both single-master and multi-master accounts. Note that for multi-master + /// accounts the first write location is simply the first region in the configured list, + /// not necessarily the hub/primary write region. + /// - Otherwise, returns null. /// public string GetLocation(Uri endpoint) {