diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RxDocumentServiceRequestTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RxDocumentServiceRequestTest.java index 7856f9a23e02..dc269aff7488 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RxDocumentServiceRequestTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RxDocumentServiceRequestTest.java @@ -417,6 +417,30 @@ public void isValidAddress(String documentUrlWithId, String documentUrlWithName, assertThat(request.isValidAddress(ResourceType.Unknown)).isTrue(); } + @Test(groups = { "unit" }) + public void cloneProducesIndependentHeaderMap() { + RxDocumentServiceRequest original = RxDocumentServiceRequest.createFromName( + mockDiagnosticsClientContext(), + OperationType.Read, + "/dbs/db/colls/coll/docs/doc", + ResourceType.Document); + original.getHeaders().put("x-test-header", "original-value"); + + RxDocumentServiceRequest cloned = original.clone(); + + // Mutating the cloned request's headers must not affect the original — guards against + // the HashMap-corruption race fixed in clone() where hedged availability-strategy + // clones previously shared the parent's header map and concurrent mutations during + // resolveEffectiveConsistencyHeaders could trigger a resize and lose entries. + cloned.getHeaders().put("x-test-header", "cloned-value"); + cloned.getHeaders().put("x-new-header", "new-value"); + + assertThat(original.getHeaders().get("x-test-header")).isEqualTo("original-value"); + assertThat(original.getHeaders().containsKey("x-new-header")).isFalse(); + assertThat(cloned.getHeaders().get("x-test-header")).isEqualTo("cloned-value"); + assertThat(cloned.getHeaders().get("x-new-header")).isEqualTo("new-value"); + } + @Test(groups = { "unit" }) public void createWithInvalidPath() { try { diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RxGatewayStoreModelTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RxGatewayStoreModelTest.java index 9d67473cced5..e72643f54d07 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RxGatewayStoreModelTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/RxGatewayStoreModelTest.java @@ -5,6 +5,7 @@ import com.azure.cosmos.ConsistencyLevel; import com.azure.cosmos.CosmosException; +import com.azure.cosmos.ReadConsistencyStrategy; import com.azure.cosmos.implementation.directconnectivity.GatewayServiceConfigurationReader; import com.azure.cosmos.implementation.directconnectivity.ReflectionUtils; import com.azure.cosmos.implementation.http.HttpClient; @@ -597,6 +598,310 @@ public void nullAdditionalHeadersDoesNotAffectPerformRequest() throws Exception assertThat(headers.toMap().get(HttpConstants.HttpHeaders.WORKLOAD_ID)).isNull(); } + // ─────────────────────────────────────────────────────────────────────────── + // ReadConsistencyStrategy × isEffectiveSessionConsistency tests + // ─────────────────────────────────────────────────────────────────────────── + + /** + * Data provider for testing session-token application through the + * {@code isEffectiveSessionConsistency} priority chain: + * request-level RCS > client-level RCS (header) > request CL > account default. + * + * Every row uses OperationType.Read + ResourceType.Document (data-plane read) + * with no user-supplied session token, so the outcome depends solely on whether + * {@code isEffectiveSessionConsistency} returns true (SDK token applied) or false + * (no token). + * + * Parameters: + * defaultConsistencyLevel – account-level default + * requestConsistencyLevel – request-level ConsistencyLevel header (nullable) + * requestContextRCS – request-level ReadConsistencyStrategy on requestContext (nullable) + * clientHeaderRCS – client-level ReadConsistencyStrategy header string (nullable) + * expectSessionTokenApplied – true ⇒ SDK session token expected; false ⇒ no token + */ + @DataProvider(name = "readConsistencyStrategySessionTokenProvider") + public Object[][] readConsistencyStrategySessionTokenProvider() { + return new Object[][]{ + // ── Request-level RCS (highest priority) ── + + // RCS=SESSION overrides everything → session token applied + {ConsistencyLevel.EVENTUAL, null, ReadConsistencyStrategy.SESSION, null, true}, + + // RCS=EVENTUAL overrides even account-default=SESSION → no token + {ConsistencyLevel.SESSION, null, ReadConsistencyStrategy.EVENTUAL, null, false}, + + // RCS=DEFAULT is transparent → falls through to account default=SESSION → applied + {ConsistencyLevel.SESSION, null, ReadConsistencyStrategy.DEFAULT, null, true}, + + // RCS=DEFAULT is transparent → falls through to account default=EVENTUAL → no token + {ConsistencyLevel.EVENTUAL, null, ReadConsistencyStrategy.DEFAULT, null, false}, + + // ── Client-level RCS header (second priority) ── + + // Client header RCS=Session → session token applied + {ConsistencyLevel.EVENTUAL, null, null, ReadConsistencyStrategy.SESSION.toString(), true}, + + // Client header RCS=Eventual → no token + {ConsistencyLevel.SESSION, null, null, ReadConsistencyStrategy.EVENTUAL.toString(), false}, + + // Client header RCS=Default is transparent → falls through to account default=SESSION + {ConsistencyLevel.SESSION, null, null, ReadConsistencyStrategy.DEFAULT.toString(), true}, + + // ── No RCS, request-level ConsistencyLevel (third priority) ── + + // CL=SESSION → session token applied + {ConsistencyLevel.EVENTUAL, ConsistencyLevel.SESSION, null, null, true}, + + // CL=EVENTUAL → no token + {ConsistencyLevel.SESSION, ConsistencyLevel.EVENTUAL, null, null, false}, + + // ── No RCS, no request CL → account default (lowest priority) ── + + // Account default=SESSION → applied + {ConsistencyLevel.SESSION, null, null, null, true}, + + // Account default=EVENTUAL → no token + {ConsistencyLevel.EVENTUAL, null, null, null, false}, + + // ── Request-level RCS overrides client-level RCS ── + + // Request RCS=SESSION beats client RCS=Eventual → applied + {ConsistencyLevel.EVENTUAL, null, ReadConsistencyStrategy.SESSION, ReadConsistencyStrategy.EVENTUAL.toString(), true}, + + // Request RCS=EVENTUAL beats client RCS=Session → no token + {ConsistencyLevel.SESSION, null, ReadConsistencyStrategy.EVENTUAL, ReadConsistencyStrategy.SESSION.toString(), false}, + + // ── Quorum-read RCS (LATEST_COMMITTED / GLOBAL_STRONG) must NEVER attach a session token ── + // These strategies execute quorum reads server-side; sending a session token would mask + // any future regression that weakens them back to session reads. + + // Request RCS=LATEST_COMMITTED on SESSION-default account → no token + {ConsistencyLevel.SESSION, null, ReadConsistencyStrategy.LATEST_COMMITTED, null, false}, + + // Request RCS=LATEST_COMMITTED on EVENTUAL-default account → no token + {ConsistencyLevel.EVENTUAL, null, ReadConsistencyStrategy.LATEST_COMMITTED, null, false}, + + // Request RCS=LATEST_COMMITTED on STRONG-default account → no token + {ConsistencyLevel.STRONG, null, ReadConsistencyStrategy.LATEST_COMMITTED, null, false}, + + // Request RCS=GLOBAL_STRONG on STRONG-default account → no token + {ConsistencyLevel.STRONG, null, ReadConsistencyStrategy.GLOBAL_STRONG, null, false}, + + // Client header RCS=LatestCommitted on SESSION-default account → no token + {ConsistencyLevel.SESSION, null, null, ReadConsistencyStrategy.LATEST_COMMITTED.toString(), false}, + + // Client header RCS=GlobalStrong on STRONG-default account → no token + {ConsistencyLevel.STRONG, null, null, ReadConsistencyStrategy.GLOBAL_STRONG.toString(), false}, + + // Request RCS=LATEST_COMMITTED beats client header RCS=Session → no token (quorum wins) + {ConsistencyLevel.SESSION, null, ReadConsistencyStrategy.LATEST_COMMITTED, ReadConsistencyStrategy.SESSION.toString(), false}, + + // Request RCS=SESSION beats client header RCS=LatestCommitted → token applied (session wins by request-level priority) + {ConsistencyLevel.EVENTUAL, null, ReadConsistencyStrategy.SESSION, ReadConsistencyStrategy.LATEST_COMMITTED.toString(), true}, + + // ── Account default=STRONG without any RCS override → quorum read, no token ── + {ConsistencyLevel.STRONG, null, null, null, false}, + + // Account default=STRONG with request CL=SESSION → request CL wins → token applied + {ConsistencyLevel.STRONG, ConsistencyLevel.SESSION, null, null, true}, + }; + } + + /** + * Validates that {@code isEffectiveSessionConsistency} (private, tested indirectly + * via {@code processMessage → applySessionToken}) correctly walks the 4-branch + * priority chain when ReadConsistencyStrategy is involved. + * + * Uses a data-plane read (Read/Document) with no user session token, so the only + * variable is the consistency resolution. If session consistency is effective the + * SDK-maintained global session token must be present; otherwise it must be absent. + */ + @Test(groups = "unit", dataProvider = "readConsistencyStrategySessionTokenProvider") + public void applySessionTokenWithReadConsistencyStrategy( + ConsistencyLevel defaultConsistency, + ConsistencyLevel requestConsistency, + ReadConsistencyStrategy requestContextRCS, + String clientHeaderRCS, + boolean expectSessionTokenApplied) throws Exception { + + String sdkGlobalSessionToken = "1#100#1=20#2=5#3=30"; + DiagnosticsClientContext clientContext = mockDiagnosticsClientContext(); + ISessionContainer sessionContainer = Mockito.mock(ISessionContainer.class); + Mockito.doReturn(sdkGlobalSessionToken).when(sessionContainer).resolveGlobalSessionToken(any()); + + GlobalEndpointManager globalEndpointManager = Mockito.mock(GlobalEndpointManager.class); + URI locationEndpointToRoute = new URI("https://localhost"); + RegionalRoutingContext regionalRoutingContext = new RegionalRoutingContext(locationEndpointToRoute); + Mockito.doReturn(regionalRoutingContext) + .when(globalEndpointManager).resolveServiceEndpoint(any()); + + HttpClient httpClient = Mockito.mock(HttpClient.class); + Mockito.doReturn(Mono.error(ReadTimeoutException.INSTANCE)) + .when(httpClient).send(any(HttpRequest.class), any(Duration.class)); + + GatewayServiceConfigurationReader gatewayServiceConfigurationReader = + Mockito.mock(GatewayServiceConfigurationReader.class); + Mockito.doReturn(defaultConsistency) + .when(gatewayServiceConfigurationReader).getDefaultConsistencyLevel(); + + RxGatewayStoreModel storeModel = new RxGatewayStoreModel( + clientContext, + sessionContainer, + defaultConsistency, + QueryCompatibilityMode.Default, + new UserAgentContainer(), + globalEndpointManager, + httpClient, + ApiType.SQL, + null); + storeModel.setGatewayServiceConfigurationReader(gatewayServiceConfigurationReader); + + RxDocumentServiceRequest dsr = RxDocumentServiceRequest.createFromName( + clientContext, + OperationType.Read, + "/fakeResourceFullName", + ResourceType.Document); + dsr.requestContext.regionalRoutingContextToRoute = regionalRoutingContext; + + // Set request-level ReadConsistencyStrategy on requestContext (highest priority) + if (requestContextRCS != null) { + dsr.requestContext.readConsistencyStrategy = requestContextRCS; + } + + // Set client-level ReadConsistencyStrategy header (second priority) + if (clientHeaderRCS != null) { + dsr.getHeaders().put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, clientHeaderRCS); + } + + // Set request-level ConsistencyLevel header (third priority) + if (requestConsistency != null) { + dsr.getHeaders().put(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL, requestConsistency.toString()); + } + + // Drive the request through processMessage → applySessionToken → isEffectiveSessionConsistency + Mono resp = storeModel.processMessage(dsr); + validateFailure(resp, FailureValidator.builder() + .instanceOf(CosmosException.class) + .causeInstanceOf(ReadTimeoutException.class) + .statusCode(HttpConstants.StatusCodes.REQUEST_TIMEOUT).build()); + + if (expectSessionTokenApplied) { + assertThat(dsr.getHeaders().get(HttpConstants.HttpHeaders.SESSION_TOKEN)) + .as("Session token should be applied (isEffectiveSessionConsistency=true)") + .isEqualTo(sdkGlobalSessionToken); + } else { + assertThat(dsr.getHeaders().get(HttpConstants.HttpHeaders.SESSION_TOKEN)) + .as("Session token should NOT be applied (isEffectiveSessionConsistency=false)") + .isNull(); + } + } + + /** + * Matrix for {@link RxGatewayStoreModel#resolveEffectiveConsistencyHeaders(Map, ReadConsistencyStrategy)}. + * + * Columns: + * requestContextRCS – request-level ReadConsistencyStrategy (nullable) + * initialHeaderRCS – existing x-ms-cosmos-read-consistency-strategy header value (nullable = absent) + * initialHeaderCL – existing x-ms-consistency-level header value (nullable = absent) + * expectedHeaderRCS – expected RCS header value after the call (null = must be absent) + * expectedHeaderCL – expected CL header value after the call (null = must be absent) + */ + @DataProvider(name = "resolveEffectiveConsistencyHeadersProvider") + public Object[][] resolveEffectiveConsistencyHeadersProvider() { + return new Object[][]{ + // 1. Request-level non-DEFAULT wins → CL stripped, RCS set from requestContext + {ReadConsistencyStrategy.LATEST_COMMITTED, null, "Session", + ReadConsistencyStrategy.LATEST_COMMITTED.toString(), null}, + + // 2. Request-level DEFAULT is transparent → header RCS (Eventual) wins → CL stripped + {ReadConsistencyStrategy.DEFAULT, ReadConsistencyStrategy.EVENTUAL.toString(), "Session", + ReadConsistencyStrategy.EVENTUAL.toString(), null}, + + // 3. Both null → CL governs, RCS stays absent + {null, null, "Session", + null, "Session"}, + + // 4. Both null/DEFAULT → strip stale DEFAULT sentinel from header, CL untouched + {ReadConsistencyStrategy.DEFAULT, ReadConsistencyStrategy.DEFAULT.toString(), "Session", + null, "Session"}, + + // 5. Header-only non-DEFAULT (GlobalStrong) → CL stripped, RCS preserved + {null, ReadConsistencyStrategy.GLOBAL_STRONG.toString(), "Session", + ReadConsistencyStrategy.GLOBAL_STRONG.toString(), null}, + + // 6. Request-level beats header on conflict → request RCS overwrites header, CL stripped + {ReadConsistencyStrategy.EVENTUAL, ReadConsistencyStrategy.LATEST_COMMITTED.toString(), "Strong", + ReadConsistencyStrategy.EVENTUAL.toString(), null}, + + // 7. Empty header value treated as absent (Strings.isNullOrEmpty) → no-op on headers + {null, "", "Session", + "", "Session"}, + + // 8. Unknown header value (not a known RCS) → helper returns null → stale header stripped + {null, "Bogus", "Session", + null, "Session"}, + + // 9. Request-level SESSION wins → CL stripped, RCS set to Session + {ReadConsistencyStrategy.SESSION, null, "Eventual", + ReadConsistencyStrategy.SESSION.toString(), null}, + + // 10. No CL present, request-level non-DEFAULT → RCS set, CL stays absent + {ReadConsistencyStrategy.GLOBAL_STRONG, null, null, + ReadConsistencyStrategy.GLOBAL_STRONG.toString(), null}, + }; + } + + /** + * Validates that {@link RxGatewayStoreModel#resolveEffectiveConsistencyHeaders(Map, ReadConsistencyStrategy)}: + * + * + * This is the cross-cutting safety net that protects both GW V1 (HTTP) and GW V2 (RNTBD via + * ThinClientStoreModel) from emitting a stale {@code DEFAULT} header on the wire. + */ + @Test(groups = "unit", dataProvider = "resolveEffectiveConsistencyHeadersProvider") + public void resolveEffectiveConsistencyHeaders_stripsDefaultAndCanonicalizes( + ReadConsistencyStrategy requestContextRCS, + String initialHeaderRCS, + String initialHeaderCL, + String expectedHeaderRCS, + String expectedHeaderCL) { + + Map headers = new HashMap<>(); + if (initialHeaderRCS != null) { + headers.put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, initialHeaderRCS); + } + if (initialHeaderCL != null) { + headers.put(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL, initialHeaderCL); + } + + RxGatewayStoreModel.resolveEffectiveConsistencyHeaders(headers, requestContextRCS); + + if (expectedHeaderRCS == null) { + assertThat(headers.get(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .as("x-ms-cosmos-read-consistency-strategy should be absent") + .isNull(); + } else { + assertThat(headers.get(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .as("x-ms-cosmos-read-consistency-strategy should equal expected value") + .isEqualTo(expectedHeaderRCS); + } + + if (expectedHeaderCL == null) { + assertThat(headers.get(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL)) + .as("x-ms-consistency-level should be absent") + .isNull(); + } else { + assertThat(headers.get(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL)) + .as("x-ms-consistency-level should equal expected value") + .isEqualTo(expectedHeaderCL); + } + } + @Test(groups = "unit") public void gatewayAddsNoRetry449Header() throws Exception { DiagnosticsClientContext clientContext = mockDiagnosticsClientContext(); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdReadConsistencyStrategyHeaderTests.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdReadConsistencyStrategyHeaderTests.java new file mode 100644 index 000000000000..330c41a133b2 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdReadConsistencyStrategyHeaderTests.java @@ -0,0 +1,473 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.cosmos.implementation.directconnectivity.rntbd; + +import com.azure.cosmos.ConsistencyLevel; +import com.azure.cosmos.ReadConsistencyStrategy; +import com.azure.cosmos.implementation.DatabaseAccount; +import com.azure.cosmos.implementation.GlobalEndpointManager; +import com.azure.cosmos.implementation.HttpConstants; +import com.azure.cosmos.implementation.ISessionContainer; +import com.azure.cosmos.implementation.OperationType; +import com.azure.cosmos.implementation.PartitionKeyRange; +import com.azure.cosmos.implementation.ResourceType; +import com.azure.cosmos.implementation.RxDocumentServiceRequest; +import com.azure.cosmos.implementation.RxGatewayStoreModel; +import com.azure.cosmos.implementation.ThinClientStoreModel; +import com.azure.cosmos.implementation.UserAgentContainer; +import com.azure.cosmos.implementation.http.HttpClient; +import com.azure.cosmos.implementation.http.HttpRequest; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.mockito.Mockito; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.net.URI; +import java.util.Map; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * Unit tests for the ReadConsistencyStrategy RNTBD header (token ID 0x00FE, Byte type). + * + *

Why these tests exist

+ * The thin client proxy (Gateway V2) deserializes ReadConsistencyStrategy from the RNTBD binary + * frame — not from HTTP headers. A mismatch between the Java SDK's enum byte values and the + * proxy's C++ enum causes the proxy to either reject the request or apply the wrong strategy + * silently. These tests guard the contract: + * + * + *

Consistency headers decision matrix

+ * Users can set ConsistencyLevel (ConsistencyLevel) and ReadConsistencyStrategy (ReadConsistencyStrategy) at both client and + * request level. The SDK resolves contention before wire serialization: + *
+ * | Client ConsistencyLevel | Client ReadConsistencyStrategy | Request ConsistencyLevel | Request ReadConsistencyStrategy | Effective on wire           |
+ * |-----------|------------|------------|-------------|-----------------------------|
+ * | Session   | —          | —          | —           | ConsistencyLevel=Session (default)        |
+ * | Session   | —          | —          | LC          | ReadConsistencyStrategy=LC, ConsistencyLevel stripped         |
+ * | Session   | Eventual   | —          | LC          | ReadConsistencyStrategy=LC (req ReadConsistencyStrategy > client)   |
+ * | Session   | Eventual   | Eventual   | —           | ReadConsistencyStrategy=Eventual, ConsistencyLevel stripped   |
+ * | Session   | —          | Eventual   | LC          | ReadConsistencyStrategy=LC (req ReadConsistencyStrategy > req ConsistencyLevel)   |
+ * | Session   | LC         | —          | —           | ReadConsistencyStrategy=LC, ConsistencyLevel stripped         |
+ * | Session   | —          | —          | GLOBAL_STRONG| BadRequestException (non-Strong acct) |
+ * 
+ * Resolution rule: request-level ReadConsistencyStrategy > client-level ReadConsistencyStrategy > ConsistencyLevel. When a non-DEFAULT ReadConsistencyStrategy is + * effective, ConsistencyLevel is stripped to prevent dual-header rejection by the compute gateway or proxy. + * + *

Test regions

+ *
    + *
  1. Token-level — RNTBD token encoding, decoding, metadata, and enum constants.
  2. + *
  3. ThinClientStoreModel encoding — {@code wrapInHttpRequest()} produces correct RNTBD + * frame bytes for each strategy value.
  4. + *
  5. Resolve + encode pipeline — {@code resolveEffectiveConsistencyHeaders()} followed by + * {@code wrapInHttpRequest()} produces the correct frame for contention scenarios (both ConsistencyLevel + * and ReadConsistencyStrategy set, request-level overrides client-level, DEFAULT is transparent).
  6. + *
+ */ +public class RntbdReadConsistencyStrategyHeaderTests { + + // region Data providers + + @DataProvider(name = "readConsistencyStrategyToRntbdByteValues") + public Object[][] readConsistencyStrategyToRntbdByteValues() { + return new Object[][] { + { ReadConsistencyStrategy.EVENTUAL, RntbdConstants.RntbdReadConsistencyStrategy.Eventual.id() }, + { ReadConsistencyStrategy.SESSION, RntbdConstants.RntbdReadConsistencyStrategy.Session.id() }, + { ReadConsistencyStrategy.LATEST_COMMITTED, RntbdConstants.RntbdReadConsistencyStrategy.LatestCommitted.id() }, + { ReadConsistencyStrategy.GLOBAL_STRONG, RntbdConstants.RntbdReadConsistencyStrategy.GlobalStrong.id() }, + }; + } + + @DataProvider(name = "readConsistencyStrategyStringToRntbdByteValues") + public Object[][] readConsistencyStrategyStringToRntbdByteValues() { + return new Object[][] { + { "LatestCommitted", (byte) 0x03 }, + { "Eventual", (byte) 0x01 }, + { "Session", (byte) 0x02 }, + { "GlobalStrong", (byte) 0x04 }, + }; + } + + // endregion + + // region Token-level tests — RNTBD token metadata, encoding, and constants + + @Test(groups = { "unit" }) + public void readConsistencyStrategyTokenMetadata() { + assertThat(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy.id()) + .isEqualTo((short) 0x00FE); + assertThat(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy.type()) + .isEqualTo(RntbdTokenType.Byte); + assertThat(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy.isRequired()) + .isFalse(); + } + + @Test(groups = { "unit" }, dataProvider = "readConsistencyStrategyToRntbdByteValues") + public void readConsistencyStrategyTokenEncodesCorrectly( + ReadConsistencyStrategy ignoredStrategy, + byte expectedByteValue) { + + RntbdToken token = RntbdToken.create( + RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(token).isNotNull(); + assertThat(token.isPresent()).isFalse(); + + token.setValue(expectedByteValue); + assertThat(token.isPresent()).isTrue(); + assertThat(((Number) token.getValue()).byteValue()).isEqualTo(expectedByteValue); + } + + @Test(groups = { "unit" }) + public void readConsistencyStrategyTokenNotPresentWhenNotSet() { + RntbdToken token = RntbdToken.create( + RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(token.isPresent()).isFalse(); + } + + // endregion + + // region Token round-trip — verifies encode/decode symmetry for the ReadConsistencyStrategy + // Byte token. Guards against RNTBD frame corruption if the token serialization format changes. + + @Test(groups = { "unit" }, dataProvider = "readConsistencyStrategyToRntbdByteValues") + public void readConsistencyStrategyTokenRoundTrips( + ReadConsistencyStrategy ignoredStrategy, + byte expectedByteValue) { + + // Encode + RntbdToken token = RntbdToken.create( + RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + token.setValue(expectedByteValue); + + ByteBuf buffer = Unpooled.buffer(256); + try { + token.encode(buffer); + + // Decode + RntbdToken decodedToken = RntbdToken.create( + RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + // skip 3 bytes: 2 for header id + 1 for token type + buffer.readerIndex(3); + decodedToken.decode(buffer); + + assertThat(decodedToken.isPresent()).isTrue(); + assertThat(((Number) decodedToken.getValue()).byteValue()).isEqualTo(expectedByteValue); + } finally { + buffer.release(); + } + } + + @Test(groups = { "unit" }) + public void readConsistencyStrategyOverWireValuesMatchEnum() { + assertThat(ReadConsistencyStrategy.EVENTUAL.toString()).isEqualTo("Eventual"); + assertThat(ReadConsistencyStrategy.SESSION.toString()).isEqualTo("Session"); + assertThat(ReadConsistencyStrategy.LATEST_COMMITTED.toString()).isEqualTo("LatestCommitted"); + assertThat(ReadConsistencyStrategy.GLOBAL_STRONG.toString()).isEqualTo("GlobalStrong"); + assertThat(ReadConsistencyStrategy.DEFAULT.toString()).isEqualTo("Default"); + } + + @Test(groups = { "unit" }) + public void readConsistencyStrategyRntbdByteEnumValues() { + assertThat(RntbdConstants.RntbdReadConsistencyStrategy.Eventual.id()).isEqualTo((byte) 0x01); + assertThat(RntbdConstants.RntbdReadConsistencyStrategy.Session.id()).isEqualTo((byte) 0x02); + assertThat(RntbdConstants.RntbdReadConsistencyStrategy.LatestCommitted.id()).isEqualTo((byte) 0x03); + assertThat(RntbdConstants.RntbdReadConsistencyStrategy.GlobalStrong.id()).isEqualTo((byte) 0x04); + } + + @Test(groups = { "unit" }) + public void readConsistencyStrategyHttpHeaderConstant() { + assertThat(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY) + .isEqualTo("x-ms-cosmos-read-consistency-strategy"); + } + + // region No contention — single header encoding. Only one of ConsistencyLevel or ReadConsistencyStrategy is set (or neither). + // No resolution needed; tests pure RNTBD encoder correctness. + + @Test(groups = { "unit" }, dataProvider = "readConsistencyStrategyStringToRntbdByteValues") + public void thinClient_wrapInHttpRequest_readConsistencyStrategyEncodedInRntbdFrame(String readConsistencyStrategyValue, byte expectedByte) throws Exception { + // Calls ThinClientStoreModel.wrapInHttpRequest() — the actual production code — + // and verifies the RNTBD frame in the HTTP body contains the correct readConsistencyStrategy byte. + + ThinClientStoreModel storeModel = createMockThinClientStoreModel(); + + RxDocumentServiceRequest request = createDocumentReadRequest(); + request.getHeaders().put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, readConsistencyStrategyValue); + request.getHeaders().remove(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL); + + HttpRequest httpRequest = + storeModel.wrapInHttpRequest(request, URI.create("https://test-proxy:10250/")); + + byte[] rntbdFrame = collectHttpBody(httpRequest); + RntbdRequest decoded = decodeRntbdFrame(rntbdFrame); + Byte rcsValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(rcsValue) + .as("RNTBD frame should contain readConsistencyStrategy 0x%02X for %s", expectedByte, readConsistencyStrategyValue) + .isEqualTo(expectedByte); + } + + @Test(groups = { "unit" }) + public void thinClient_wrapInHttpRequest_noReadConsistencyStrategyHeader_noRntbdToken() throws Exception { + ThinClientStoreModel storeModel = createMockThinClientStoreModel(); + + RxDocumentServiceRequest request = createDocumentReadRequest(); + // No readConsistencyStrategy header set + + HttpRequest httpRequest = + storeModel.wrapInHttpRequest(request, URI.create("https://test-proxy:10250/")); + + byte[] rntbdFrame = collectHttpBody(httpRequest); + RntbdRequest decoded = decodeRntbdFrame(rntbdFrame); + Byte rcsValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(rcsValue) + .as("ReadConsistencyStrategy token should not be set when header is absent (0 = unset)") + .isEqualTo((byte) 0); + } + + @Test(groups = { "unit" }) + public void thinClient_wrapInHttpRequest_readConsistencyStrategyPresent_consistencyLevelAbsent() throws Exception { + ThinClientStoreModel storeModel = createMockThinClientStoreModel(); + + RxDocumentServiceRequest request = createDocumentReadRequest(); + request.getHeaders().put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, "LatestCommitted"); + request.getHeaders().remove(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL); + + HttpRequest httpRequest = + storeModel.wrapInHttpRequest(request, URI.create("https://test-proxy:10250/")); + + byte[] rntbdFrame = collectHttpBody(httpRequest); + RntbdRequest decoded = decodeRntbdFrame(rntbdFrame); + Byte rcsValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(rcsValue) + .as("ReadConsistencyStrategy should be LatestCommitted (0x03)") + .isEqualTo((byte) 0x03); + Byte clValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ConsistencyLevel); + assertThat(clValue) + .as("ConsistencyLevel should not be set when ReadConsistencyStrategy is present (0 = unset)") + .isEqualTo((byte) 0); + } + + // endregion + + // region Contention — both ConsistencyLevel and ReadConsistencyStrategy headers present. Tests resolveEffectiveConsistencyHeaders() + // followed by wrapInHttpRequest() to verify the correct header wins on the wire. + + @Test(groups = { "unit" }) + public void thinClient_resolveAndWrap_bothClAndReadConsistencyStrategy_onlyReadConsistencyStrategySurvivesInFrame() throws Exception { + // End-to-end chain: dirty headers (both ConsistencyLevel and readConsistencyStrategy set) + // → resolveEffectiveConsistencyHeaders (strips ConsistencyLevel) + // → wrapInHttpRequest (encodes RNTBD frame) + // → verify only readConsistencyStrategy in the frame, ConsistencyLevel absent + ThinClientStoreModel storeModel = createMockThinClientStoreModel(); + + RxDocumentServiceRequest request = createDocumentReadRequest(); + // Pre-resolution state: both headers present (as getRequestHeaders would set them + // before resolveEffectiveConsistencyHeaders runs in performRequestInternalCore) + request.getHeaders().put(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL, "Session"); + request.getHeaders().put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, "LatestCommitted"); + + // Run the same resolution logic that performRequestInternalCore() calls + resolveEffectiveConsistencyHeaders(request); + + // Now call wrapInHttpRequest with the resolved headers + HttpRequest httpRequest = + storeModel.wrapInHttpRequest(request, URI.create("https://test-proxy:10250/")); + + byte[] rntbdFrame = collectHttpBody(httpRequest); + RntbdRequest decoded = decodeRntbdFrame(rntbdFrame); + Byte rcsValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(rcsValue) + .as("readConsistencyStrategy=LatestCommitted (0x03) should survive in the RNTBD frame") + .isEqualTo((byte) 0x03); + Byte clValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ConsistencyLevel); + assertThat(clValue) + .as("ConsistencyLevel should be stripped — only readConsistencyStrategy survives on the wire (0 = unset)") + .isEqualTo((byte) 0); + } + + @Test(groups = { "unit" }) + public void thinClient_resolveAndWrap_requestContextReadConsistencyStrategy_overridesHeaderReadConsistencyStrategy() throws Exception { + // Header-level ReadConsistencyStrategy ("Eventual") = set by CosmosClientBuilder.readConsistencyStrategy(), + // applied to every request via getRequestHeaders(). + // Request-level ReadConsistencyStrategy (LATEST_COMMITTED) = set by CosmosItemRequestOptions.setReadConsistencyStrategy(), + // a per-operation override stored in requestContext. + // Resolution rule: requestContext (per-request) > headers (client-level). + ThinClientStoreModel storeModel = createMockThinClientStoreModel(); + + RxDocumentServiceRequest request = createDocumentReadRequest(); + request.getHeaders().put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, "Eventual"); // client-level + request.getHeaders().put(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL, "Session"); + request.requestContext.readConsistencyStrategy = ReadConsistencyStrategy.LATEST_COMMITTED; // per-request override + + resolveEffectiveConsistencyHeaders(request); + + HttpRequest httpRequest = + storeModel.wrapInHttpRequest(request, URI.create("https://test-proxy:10250/")); + + byte[] rntbdFrame = collectHttpBody(httpRequest); + RntbdRequest decoded = decodeRntbdFrame(rntbdFrame); + Byte rcsValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(rcsValue) + .as("Request-level readConsistencyStrategy=LatestCommitted should override header-level Eventual") + .isEqualTo((byte) 0x03); + Byte clValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ConsistencyLevel); + assertThat(clValue) + .as("ConsistencyLevel should be stripped (0 = unset)") + .isEqualTo((byte) 0); + } + + @Test(groups = { "unit" }) + public void thinClient_resolveAndWrap_defaultReadConsistencyStrategy_consistencyLevelSurvives() throws Exception { + // DEFAULT readConsistencyStrategy is transparent — ConsistencyLevel should remain in the RNTBD frame + ThinClientStoreModel storeModel = createMockThinClientStoreModel(); + + RxDocumentServiceRequest request = createDocumentReadRequest(); + request.getHeaders().put(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL, "Session"); + request.requestContext.readConsistencyStrategy = ReadConsistencyStrategy.DEFAULT; + + resolveEffectiveConsistencyHeaders(request); + + HttpRequest httpRequest = + storeModel.wrapInHttpRequest(request, URI.create("https://test-proxy:10250/")); + + byte[] rntbdFrame = collectHttpBody(httpRequest); + RntbdRequest decoded = decodeRntbdFrame(rntbdFrame); + Byte rcsValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(rcsValue) + .as("DEFAULT readConsistencyStrategy should not be set (0 = unset)") + .isEqualTo((byte) 0); + Byte clValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ConsistencyLevel); + assertThat(clValue) + .as("ConsistencyLevel=Session should survive when readConsistencyStrategy is DEFAULT") + .isEqualTo(RntbdConstants.RntbdConsistencyLevel.Session.id()); + } + + @Test(groups = { "unit" }) + public void resolve_noHeaders_noOp() { + Map headers = new java.util.HashMap<>(); + RxGatewayStoreModel.resolveEffectiveConsistencyHeaders(headers, null); + assertThat(headers.size()).isEqualTo(0); + } + + @Test(groups = { "unit" }) + public void resolve_idempotent_multipleInvocations() { + // Resolution should be idempotent — multiple calls produce the same result. + // Validates safety for shared header maps across availability strategy clones. + Map headers = new java.util.HashMap<>(); + headers.put(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL, "Session"); + headers.put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, "LatestCommitted"); + + RxGatewayStoreModel.resolveEffectiveConsistencyHeaders(headers, null); + RxGatewayStoreModel.resolveEffectiveConsistencyHeaders(headers, null); + RxGatewayStoreModel.resolveEffectiveConsistencyHeaders(headers, null); + + assertThat(headers.containsKey(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL)).isFalse(); + assertThat(headers.get(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .isEqualTo("LatestCommitted"); + } + + // endregion + + // region Invariant — every non-DEFAULT enum value must produce a non-zero RNTBD byte. + + /** + * Guard against silent drops in {@code RntbdRequestHeaders.addReadConsistencyStrategy()}'s switch + * statement. The switch ends with {@code default: assert false; break;} — in production JVMs + * ({@code -da} is the default) a new {@link ReadConsistencyStrategy} enum value would slip through + * without emitting an RNTBD byte and the thin-client proxy would silently apply the wrong + * consistency. This test iterates {@link ReadConsistencyStrategy#values()} so adding a new value + * forces this test to fail loudly until the switch is updated. + */ + @Test(groups = { "unit" }) + public void thinClient_wrapInHttpRequest_allNonDefaultEnumValues_emitNonZeroRntbdByte() throws Exception { + ThinClientStoreModel storeModel = createMockThinClientStoreModel(); + + for (ReadConsistencyStrategy strategy : ReadConsistencyStrategy.values()) { + if (strategy == ReadConsistencyStrategy.DEFAULT) { + // DEFAULT is intentionally transparent — no RNTBD byte should be written. + continue; + } + + RxDocumentServiceRequest request = createDocumentReadRequest(); + request.getHeaders().put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, strategy.toString()); + request.getHeaders().remove(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL); + + HttpRequest httpRequest = + storeModel.wrapInHttpRequest(request, URI.create("https://test-proxy:10250/")); + + byte[] rntbdFrame = collectHttpBody(httpRequest); + RntbdRequest decoded = decodeRntbdFrame(rntbdFrame); + Byte rcsValue = decoded.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + + assertThat(rcsValue) + .as("ReadConsistencyStrategy.%s must encode to a non-zero RNTBD byte. " + + "If you added a new enum value, update RntbdRequestHeaders.addReadConsistencyStrategy() " + + "to map it to a RntbdConstants.RntbdReadConsistencyStrategy id.", strategy) + .isNotEqualTo((byte) 0); + } + } + + // endregion + + // region Helpers + + private static ThinClientStoreModel createMockThinClientStoreModel() { + DatabaseAccount mockAccount = Mockito.mock(DatabaseAccount.class); + Mockito.when(mockAccount.getId()).thenReturn("test-account"); + + GlobalEndpointManager mockGem = Mockito.mock(GlobalEndpointManager.class); + Mockito.when(mockGem.getLatestDatabaseAccount()).thenReturn(mockAccount); + + return new ThinClientStoreModel( + null, // clientContext — not used in wrapInHttpRequest + Mockito.mock(ISessionContainer.class), + ConsistencyLevel.SESSION, + new UserAgentContainer(), + mockGem, + Mockito.mock(HttpClient.class), + null); + } + + private static RxDocumentServiceRequest createDocumentReadRequest() { + RxDocumentServiceRequest request = RxDocumentServiceRequest.createFromName( + null, OperationType.Read, "dbs/testdb/colls/testcoll/docs/testdoc", ResourceType.Document); + // Set resolved partition key range to avoid NPE in wrapInHttpRequest + request.requestContext.resolvedPartitionKeyRange = new PartitionKeyRange(); + request.requestContext.resolvedPartitionKeyRange.setMinInclusive("00"); + request.requestContext.resolvedPartitionKeyRange.setMaxExclusive("FF"); + return request; + } + + private static byte[] collectHttpBody(HttpRequest httpRequest) { + return httpRequest.body().reduce((a, b) -> { + byte[] merged = new byte[a.length + b.length]; + System.arraycopy(a, 0, merged, 0, a.length); + System.arraycopy(b, 0, merged, a.length, b.length); + return merged; + }).block(); + } + + private static void resolveEffectiveConsistencyHeaders(RxDocumentServiceRequest request) { + RxGatewayStoreModel.resolveEffectiveConsistencyHeaders( + request.getHeaders(), + request.requestContext != null ? request.requestContext.readConsistencyStrategy : null); + } + + // region RNTBD frame helpers + + /** + * Decodes the RNTBD binary frame using the production decoder. + * Token presence/absence is determined by the actual RNTBD wire format. + */ + private static RntbdRequest decodeRntbdFrame(byte[] rntbdFrame) { + ByteBuf buffer = Unpooled.wrappedBuffer(rntbdFrame); + return RntbdRequest.decode(buffer); + } + + // endregion +} diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/GatewayReadConsistencyStrategyE2ETest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/GatewayReadConsistencyStrategyE2ETest.java new file mode 100644 index 000000000000..0666841e53c1 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/GatewayReadConsistencyStrategyE2ETest.java @@ -0,0 +1,742 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.cosmos.rx; + +import com.azure.cosmos.ConsistencyLevel; +import com.azure.cosmos.CosmosAsyncClient; +import com.azure.cosmos.CosmosAsyncContainer; +import com.azure.cosmos.CosmosAsyncDatabase; +import com.azure.cosmos.CosmosBridgeInternal; +import com.azure.cosmos.CosmosClientBuilder; +import com.azure.cosmos.CosmosDiagnostics; +import com.azure.cosmos.FlakyTestRetryAnalyzer; +import com.azure.cosmos.GatewayConnectionConfig; +import com.azure.cosmos.Http2ConnectionConfig; +import com.azure.cosmos.ReadConsistencyStrategy; +import com.azure.cosmos.implementation.BadRequestException; +import com.azure.cosmos.implementation.TestConfigurations; +import com.azure.cosmos.models.CosmosChangeFeedRequestOptions; +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.CosmosQueryRequestOptions; +import com.azure.cosmos.models.CosmosReadManyRequestOptions; +import com.azure.cosmos.models.CosmosRequestOptions; +import com.azure.cosmos.models.FeedRange; +import com.azure.cosmos.models.FeedResponse; +import com.azure.cosmos.models.PartitionKey; +import com.azure.cosmos.models.SqlParameter; +import com.azure.cosmos.models.SqlQuerySpec; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.SkipException; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +/** + * Unified E2E tests for ReadConsistencyStrategy propagation across Gateway V1 and Gateway V2. + * + *

Creates two {@link CosmosAsyncClient} instances — one for each transport: + *

    + *
  • Gateway V1 (HTTP/1): Standard gateway mode.
  • + *
  • Gateway V2 (HTTP/2 + thin client): Gateway mode with thin client JVM flag enabled. + * V2 tests additionally assert that requests target the thin client endpoint ({@code :10250}).
  • + *
+ * + *

Does not extend {@link TestSuiteBase} to avoid {@code @BeforeSuite} shared container + * initialization which requires provisioned throughput (incompatible with serverless accounts). + * Uses {@link TestSuiteBase#createCollection(CosmosAsyncClient, String, CosmosContainerProperties)} + * as a static utility for serverless-safe container creation. + * + *

Run with test group "thinclient". + */ +public class GatewayReadConsistencyStrategyE2ETest { + private static final Logger logger = LoggerFactory.getLogger(GatewayReadConsistencyStrategyE2ETest.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final long TIMEOUT = 60_000L; + + private static final String GATEWAY_V1 = "GatewayV1"; + private static final String GATEWAY_V2 = "GatewayV2"; + private static final String DIRECT = "Direct"; + + private CosmosAsyncClient gatewayV1Client; + private CosmosAsyncClient gatewayV2Client; + private CosmosAsyncClient directClient; + private CosmosAsyncDatabase database; + private CosmosAsyncContainer gatewayV1Container; + private CosmosAsyncContainer gatewayV2Container; + private CosmosAsyncContainer directContainer; + private String databaseId; + private String containerId; + + @BeforeClass(groups = {"thinclient"}, timeOut = TIMEOUT) + public void beforeClass() { + System.setProperty("COSMOS.THINCLIENT_ENABLED", "true"); + + databaseId = "readConsistencyStrategy-e2e-" + UUID.randomUUID().toString().substring(0, 8); + containerId = "testcontainer"; + + gatewayV1Client = createGatewayV1Builder().buildAsyncClient(); + + gatewayV1Client.createDatabaseIfNotExists(databaseId).block(); + database = gatewayV1Client.getDatabase(databaseId); + + CosmosContainerProperties containerProperties = new CosmosContainerProperties(containerId, "/pk"); + TestSuiteBase.createCollection(gatewayV1Client, databaseId, containerProperties); + + gatewayV1Container = gatewayV1Client.getDatabase(databaseId).getContainer(containerId); + + // Gateway V2 client — HTTP/2 enabled, thin client flag set + gatewayV2Client = createGatewayV2Builder().buildAsyncClient(); + gatewayV2Container = gatewayV2Client.getDatabase(databaseId).getContainer(containerId); + + // Direct mode client + directClient = createDirectBuilder().buildAsyncClient(); + directContainer = directClient.getDatabase(databaseId).getContainer(containerId); + + logger.info("Created E2E test resources: db={}, container={}", databaseId, containerId); + } + + @AfterClass(groups = {"thinclient"}, alwaysRun = true) + public void afterClass() { + if (database != null) { + try { + database.delete().block(); + } catch (Exception e) { + logger.warn("Failed to delete test database", e); + } + } + safeClose(gatewayV1Client); + safeClose(gatewayV2Client); + safeClose(directClient); + } + + @DataProvider(name = "transportModes") + public Object[][] transportModes() { + return new Object[][] { { GATEWAY_V1 }, { GATEWAY_V2 }, { DIRECT } }; + } + + // region ItemRequestOptions — point reads + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void readItem_withLatestCommitted(String mode) { + String id = seedDocument(mode); + + CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + CosmosItemResponse response = + containerFor(mode).readItem(id, new PartitionKey(id), readOptions, ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getDiagnostics()); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void readItem_withEventual(String mode) { + String id = seedDocument(mode); + + CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.EVENTUAL); + + CosmosItemResponse response = + containerFor(mode).readItem(id, new PartitionKey(id), readOptions, ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.EVENTUAL); + assertEndpointForMode(mode, response.getDiagnostics()); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void readItem_withSession(String mode) { + String id = seedDocument(mode); + + CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.SESSION); + + CosmosItemResponse response = + containerFor(mode).readItem(id, new PartitionKey(id), readOptions, ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.SESSION); + assertEndpointForMode(mode, response.getDiagnostics()); + } + + // endregion + + // region QueryRequestOptions + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void queryItems_withLatestCommitted(String mode) { + String id = seedDocument(mode); + + CosmosQueryRequestOptions queryOptions = new CosmosQueryRequestOptions() + .setPartitionKey(new PartitionKey(id)) + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + SqlQuerySpec querySpec = new SqlQuerySpec("SELECT * FROM c WHERE c.id=@id"); + querySpec.setParameters(Arrays.asList(new SqlParameter("@id", id))); + + FeedResponse response = containerFor(mode) + .queryItems(querySpec, queryOptions, ObjectNode.class) + .byPage() + .blockFirst(); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotNull(); + assertEffectiveReadConsistencyStrategy(response.getCosmosDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getCosmosDiagnostics()); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void queryItems_withSession(String mode) { + String id = seedDocument(mode); + + CosmosQueryRequestOptions queryOptions = new CosmosQueryRequestOptions() + .setPartitionKey(new PartitionKey(id)) + .setReadConsistencyStrategy(ReadConsistencyStrategy.SESSION); + + SqlQuerySpec querySpec = new SqlQuerySpec("SELECT * FROM c WHERE c.id=@id"); + querySpec.setParameters(Arrays.asList(new SqlParameter("@id", id))); + + FeedResponse response = containerFor(mode) + .queryItems(querySpec, queryOptions, ObjectNode.class) + .byPage() + .blockFirst(); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotNull(); + assertEffectiveReadConsistencyStrategy(response.getCosmosDiagnostics(), ReadConsistencyStrategy.SESSION); + assertEndpointForMode(mode, response.getCosmosDiagnostics()); + } + + // endregion + + // region ReadAllItems + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void readAllItems_withLatestCommitted(String mode) { + String pk = UUID.randomUUID().toString(); + seedDocument(mode, UUID.randomUUID().toString(), pk); + + CosmosQueryRequestOptions queryOptions = new CosmosQueryRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + FeedResponse response = containerFor(mode) + .readAllItems(new PartitionKey(pk), queryOptions, ObjectNode.class) + .byPage() + .blockFirst(); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotNull(); + assertEffectiveReadConsistencyStrategy(response.getCosmosDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getCosmosDiagnostics()); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void readAllItems_withSession(String mode) { + String pk = UUID.randomUUID().toString(); + seedDocument(mode, UUID.randomUUID().toString(), pk); + + CosmosQueryRequestOptions queryOptions = new CosmosQueryRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.SESSION); + + FeedResponse response = containerFor(mode) + .readAllItems(new PartitionKey(pk), queryOptions, ObjectNode.class) + .byPage() + .blockFirst(); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotNull(); + assertEffectiveReadConsistencyStrategy(response.getCosmosDiagnostics(), ReadConsistencyStrategy.SESSION); + assertEndpointForMode(mode, response.getCosmosDiagnostics()); + } + + // endregion + + // region ChangeFeedRequestOptions + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void changeFeed_withLatestCommitted(String mode) { + String pkValue = UUID.randomUUID().toString(); + seedDocument(mode, pkValue, pkValue); + + CosmosChangeFeedRequestOptions cfOptions = CosmosChangeFeedRequestOptions + .createForProcessingFromBeginning( + FeedRange.forLogicalPartition(new PartitionKey(pkValue))) + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + List> pages = containerFor(mode) + .queryChangeFeed(cfOptions, ObjectNode.class) + .byPage() + .collectList() + .block(); + + assertThat(pages).isNotNull(); + assertThat(pages.isEmpty()).isFalse(); + + FeedResponse firstPage = pages.get(0); + assertEffectiveReadConsistencyStrategy(firstPage.getCosmosDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, firstPage.getCosmosDiagnostics()); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void changeFeed_withSession(String mode) { + String pkValue = UUID.randomUUID().toString(); + seedDocument(mode, pkValue, pkValue); + + CosmosChangeFeedRequestOptions cfOptions = CosmosChangeFeedRequestOptions + .createForProcessingFromBeginning( + FeedRange.forLogicalPartition(new PartitionKey(pkValue))) + .setReadConsistencyStrategy(ReadConsistencyStrategy.SESSION); + + List> pages = containerFor(mode) + .queryChangeFeed(cfOptions, ObjectNode.class) + .byPage() + .collectList() + .block(); + + assertThat(pages).isNotNull(); + assertThat(pages.isEmpty()).isFalse(); + + FeedResponse firstPage = pages.get(0); + assertEffectiveReadConsistencyStrategy(firstPage.getCosmosDiagnostics(), ReadConsistencyStrategy.SESSION); + assertEndpointForMode(mode, firstPage.getCosmosDiagnostics()); + } + + // endregion + + // region ReadManyRequestOptions + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void readMany_withLatestCommitted(String mode) { + String pkValue = UUID.randomUUID().toString(); + String id1 = UUID.randomUUID().toString(); + String id2 = UUID.randomUUID().toString(); + seedDocument(mode, id1, pkValue); + seedDocument(mode, id2, pkValue); + + List identities = Arrays.asList( + new CosmosItemIdentity(new PartitionKey(pkValue), id1), + new CosmosItemIdentity(new PartitionKey(pkValue), id2)); + + CosmosReadManyRequestOptions readManyOptions = new CosmosReadManyRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + FeedResponse response = + containerFor(mode).readMany(identities, readManyOptions, ObjectNode.class).block(); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotNull(); + assertThat(response.getResults().size()).isEqualTo(2); + assertEffectiveReadConsistencyStrategy(response.getCosmosDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getCosmosDiagnostics()); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void readMany_withSession(String mode) { + String pkValue = UUID.randomUUID().toString(); + String id1 = UUID.randomUUID().toString(); + String id2 = UUID.randomUUID().toString(); + seedDocument(mode, id1, pkValue); + seedDocument(mode, id2, pkValue); + + List identities = Arrays.asList( + new CosmosItemIdentity(new PartitionKey(pkValue), id1), + new CosmosItemIdentity(new PartitionKey(pkValue), id2)); + + CosmosReadManyRequestOptions readManyOptions = new CosmosReadManyRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.SESSION); + + FeedResponse response = + containerFor(mode).readMany(identities, readManyOptions, ObjectNode.class).block(); + + assertThat(response).isNotNull(); + assertThat(response.getResults()).isNotNull(); + assertThat(response.getResults().size()).isEqualTo(2); + assertEffectiveReadConsistencyStrategy(response.getCosmosDiagnostics(), ReadConsistencyStrategy.SESSION); + assertEndpointForMode(mode, response.getCosmosDiagnostics()); + } + + // endregion + + // region Client-level ReadConsistencyStrategy + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void clientLevel_latestCommitted_readItem(String mode) { + CosmosAsyncClient clientWithReadConsistencyStrategy = null; + try { + clientWithReadConsistencyStrategy = builderFor(mode) + .readConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED) + .buildAsyncClient(); + CosmosAsyncContainer targetContainer = clientWithReadConsistencyStrategy.getDatabase(databaseId).getContainer(containerId); + + String id = UUID.randomUUID().toString(); + createAndInsertDocument(targetContainer, id); + + CosmosItemResponse response = + targetContainer.readItem(id, new PartitionKey(id), ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getDiagnostics()); + } finally { + safeClose(clientWithReadConsistencyStrategy); + } + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void clientLevel_session_readItem(String mode) { + CosmosAsyncClient clientWithReadConsistencyStrategy = null; + try { + clientWithReadConsistencyStrategy = builderFor(mode) + .readConsistencyStrategy(ReadConsistencyStrategy.SESSION) + .buildAsyncClient(); + CosmosAsyncContainer targetContainer = clientWithReadConsistencyStrategy.getDatabase(databaseId).getContainer(containerId); + + String id = UUID.randomUUID().toString(); + createAndInsertDocument(targetContainer, id); + + CosmosItemResponse response = + targetContainer.readItem(id, new PartitionKey(id), ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.SESSION); + assertEndpointForMode(mode, response.getDiagnostics()); + } finally { + safeClose(clientWithReadConsistencyStrategy); + } + } + + // endregion + + // region Write operations— ReadConsistencyStrategy forced to DEFAULT + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void writeItem_readConsistencyStrategyIgnored(String mode) { + CosmosAsyncClient clientWithReadConsistencyStrategy = null; + try { + clientWithReadConsistencyStrategy = builderFor(mode) + .readConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED) + .buildAsyncClient(); + CosmosAsyncContainer targetContainer = clientWithReadConsistencyStrategy.getDatabase(databaseId).getContainer(containerId); + + String id = UUID.randomUUID().toString(); + ObjectNode doc = createDocument(id); + + CosmosItemResponse response = + targetContainer.createItem(doc, new PartitionKey(id), null).block(); + + assertThat(response).isNotNull(); + assertThat(response.getStatusCode()).isEqualTo(201); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.DEFAULT); + } finally { + safeClose(clientWithReadConsistencyStrategy); + } + } + + // endregion + + // region Validation — GLOBAL_STRONG on Session account + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void readItem_globalStrong_invalidAccount_throwsBadRequest(String mode) { + // Account-level consistency is global; always probe via gatewayV1Client to avoid hitting + // a Direct-mode DatabaseAccount read path that can NPE on thin-client endpoints + // (regionalRoutingContextToRoute null in StoreReader). + ConsistencyLevel accountLevel = CosmosBridgeInternal.getAsyncDocumentClient(gatewayV1Client) + .getDatabaseAccount() + .map(account -> ConsistencyLevel.valueOf(account.getConsistencyPolicy().getDefaultConsistencyLevel().name())) + .block(); + if (accountLevel == ConsistencyLevel.STRONG) { + throw new SkipException( + "Skipping " + mode + " — account default consistency is STRONG; GLOBAL_STRONG is valid here so the " + + "BadRequest assertion does not apply."); + } + + String id = seedDocument(mode); + + CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.GLOBAL_STRONG); + + Throwable caughtError = null; + try { + containerFor(mode).readItem(id, new PartitionKey(id), readOptions, ObjectNode.class).block(); + } catch (Throwable t) { + caughtError = t; + } + + assertThat(caughtError) + .as("Expected BadRequestException for GLOBAL_STRONG on Session account") + .isNotNull() + .isInstanceOf(BadRequestException.class); + assertThat(caughtError.getMessage()).contains("read-consistency-strategy"); + } + + // endregion + + // region Contention — both ConsistencyLevel and ReadConsistencyStrategy set + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void clientLevel_bothConsistencyLevelAndReadConsistencyStrategy_readConsistencyStrategyWins(String mode) { + CosmosAsyncClient clientWithBoth = null; + try { + clientWithBoth = builderFor(mode) + .consistencyLevel(ConsistencyLevel.EVENTUAL) + .readConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED) + .buildAsyncClient(); + CosmosAsyncContainer targetContainer = clientWithBoth.getDatabase(databaseId).getContainer(containerId); + + String id = UUID.randomUUID().toString(); + createAndInsertDocument(targetContainer, id); + + CosmosItemResponse response = + targetContainer.readItem(id, new PartitionKey(id), ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getDiagnostics()); + } finally { + safeClose(clientWithBoth); + } + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void requestLevel_bothConsistencyLevelAndReadConsistencyStrategy_readConsistencyStrategyWins(String mode) { + String id = seedDocument(mode); + + CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions() + .setConsistencyLevel(ConsistencyLevel.EVENTUAL) + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + CosmosItemResponse response = + containerFor(mode).readItem(id, new PartitionKey(id), readOptions, ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getDiagnostics()); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void requestLevelReadConsistencyStrategy_overridesClientLevelReadConsistencyStrategy(String mode) { + CosmosAsyncClient clientWithClientReadConsistencyStrategy = null; + try { + clientWithClientReadConsistencyStrategy = builderFor(mode) + .readConsistencyStrategy(ReadConsistencyStrategy.EVENTUAL) + .buildAsyncClient(); + CosmosAsyncContainer targetContainer = clientWithClientReadConsistencyStrategy.getDatabase(databaseId).getContainer(containerId); + + String id = UUID.randomUUID().toString(); + createAndInsertDocument(targetContainer, id); + + CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + CosmosItemResponse response = + targetContainer.readItem(id, new PartitionKey(id), readOptions, ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getDiagnostics()); + } finally { + safeClose(clientWithClientReadConsistencyStrategy); + } + } + + // endregion + + // region Operation policy + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void operationPolicy_setsReadConsistencyStrategy(String mode) { + CosmosAsyncClient policyClient = null; + try { + policyClient = builderFor(mode) + .addOperationPolicy(cosmosOperationDetails -> { + CosmosRequestOptions overrides = new CosmosRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + cosmosOperationDetails.setRequestOptions(overrides); + }) + .buildAsyncClient(); + CosmosAsyncContainer policyContainer = policyClient.getDatabase(databaseId).getContainer(containerId); + + String id = UUID.randomUUID().toString(); + createAndInsertDocument(policyContainer, id); + + CosmosItemResponse response = + policyContainer.readItem(id, new PartitionKey(id), ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.LATEST_COMMITTED); + assertEndpointForMode(mode, response.getDiagnostics()); + } finally { + safeClose(policyClient); + } + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes", retryAnalyzer = FlakyTestRetryAnalyzer.class) + public void operationPolicy_readConsistencyStrategyOverridesRequestLevel(String mode) { + CosmosAsyncClient policyClient = null; + try { + policyClient = builderFor(mode) + .addOperationPolicy(cosmosOperationDetails -> { + CosmosRequestOptions overrides = new CosmosRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.EVENTUAL); + cosmosOperationDetails.setRequestOptions(overrides); + }) + .buildAsyncClient(); + CosmosAsyncContainer policyContainer = policyClient.getDatabase(databaseId).getContainer(containerId); + + String id = UUID.randomUUID().toString(); + createAndInsertDocument(policyContainer, id); + + CosmosItemRequestOptions readOptions = new CosmosItemRequestOptions() + .setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + CosmosItemResponse response = + policyContainer.readItem(id, new PartitionKey(id), readOptions, ObjectNode.class).block(); + + assertSuccessfulRead(response); + assertEffectiveReadConsistencyStrategy(response.getDiagnostics(), ReadConsistencyStrategy.EVENTUAL); + assertEndpointForMode(mode, response.getDiagnostics()); + } finally { + safeClose(policyClient); + } + } + + // endregion + + // region Helpers — builders + + private static CosmosClientBuilder createGatewayV1Builder() { + return new CosmosClientBuilder() + .endpoint(TestConfigurations.HOST) + .key(TestConfigurations.MASTER_KEY) + .gatewayMode() + .consistencyLevel(ConsistencyLevel.SESSION); + } + + private static CosmosClientBuilder createGatewayV2Builder() { + GatewayConnectionConfig gwConfig = new GatewayConnectionConfig(); + gwConfig.setHttp2ConnectionConfig(new Http2ConnectionConfig().setEnabled(true)); + + return new CosmosClientBuilder() + .endpoint(TestConfigurations.HOST) + .key(TestConfigurations.MASTER_KEY) + .gatewayMode(gwConfig) + .consistencyLevel(ConsistencyLevel.SESSION); + } + + private static CosmosClientBuilder createDirectBuilder() { + return new CosmosClientBuilder() + .endpoint(TestConfigurations.HOST) + .key(TestConfigurations.MASTER_KEY) + .directMode() + .consistencyLevel(ConsistencyLevel.SESSION); + } + + private CosmosClientBuilder builderFor(String mode) { + switch (mode) { + case GATEWAY_V2: return createGatewayV2Builder(); + case DIRECT: return createDirectBuilder(); + default: return createGatewayV1Builder(); + } + } + + private CosmosAsyncContainer containerFor(String mode) { + switch (mode) { + case GATEWAY_V2: return gatewayV2Container; + case DIRECT: return directContainer; + default: return gatewayV1Container; + } + } + + private CosmosAsyncClient clientFor(String mode) { + switch (mode) { + case GATEWAY_V2: return gatewayV2Client; + case DIRECT: return directClient; + default: return gatewayV1Client; + } + } + + private static boolean isGatewayV2(String mode) { + return GATEWAY_V2.equals(mode); + } + + // endregion + + // region Helpers — documents + + private ObjectNode createDocument(String id) { + return createDocument(id, id); + } + + private ObjectNode createDocument(String id, String pk) { + ObjectNode doc = OBJECT_MAPPER.createObjectNode(); + doc.put("id", id); + doc.put("pk", pk); + return doc; + } + + private String seedDocument(String mode) { + String id = UUID.randomUUID().toString(); + seedDocument(mode, id, id); + return id; + } + + private void seedDocument(String mode, String id, String pk) { + ObjectNode doc = createDocument(id, pk); + containerFor(mode).createItem(doc, new PartitionKey(pk), null).block(); + } + + private void createAndInsertDocument(CosmosAsyncContainer targetContainer, String id) { + ObjectNode doc = createDocument(id); + targetContainer.createItem(doc, new PartitionKey(id), null).block(); + } + + // endregion + + // region Helpers — assertions + + private static void assertSuccessfulRead(CosmosItemResponse response) { + assertThat(response).isNotNull(); + assertThat(response.getStatusCode()).isEqualTo(200); + } + + private static void assertEffectiveReadConsistencyStrategy(CosmosDiagnostics diagnostics, ReadConsistencyStrategy expected) { + assertThat(diagnostics).isNotNull(); + assertThat(diagnostics.getDiagnosticsContext()).isNotNull(); + assertThat(diagnostics.getDiagnosticsContext().getEffectiveReadConsistencyStrategy()) + .isEqualTo(expected); + } + + private void assertEndpointForMode(String mode, CosmosDiagnostics diagnostics) { + if (isGatewayV2(mode)) { + TestSuiteBase.assertThinClientEndpointUsed(diagnostics); + } + } + + private static void safeClose(CosmosAsyncClient clientToClose) { + if (clientToClose != null) { + try { + clientToClose.close(); + } catch (Exception e) { + logger.warn("Failed to close client", e); + } + } + } + + // endregion +} diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/GatewayReadConsistencyStrategySpyWireTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/GatewayReadConsistencyStrategySpyWireTest.java new file mode 100644 index 000000000000..5a2390408e20 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/GatewayReadConsistencyStrategySpyWireTest.java @@ -0,0 +1,801 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.cosmos.rx; + +import com.azure.cosmos.ConsistencyLevel; +import com.azure.cosmos.CosmosAsyncClient; +import com.azure.cosmos.CosmosAsyncContainer; +import com.azure.cosmos.CosmosAsyncDatabase; +import com.azure.cosmos.CosmosClientBuilder; +import com.azure.cosmos.GatewayConnectionConfig; +import com.azure.cosmos.Http2ConnectionConfig; +import com.azure.cosmos.ReadConsistencyStrategy; +import com.azure.cosmos.implementation.ChangeFeedOperationState; +import com.azure.cosmos.implementation.Configs; +import com.azure.cosmos.implementation.ConnectionPolicy; +import com.azure.cosmos.implementation.CosmosPagedFluxOptions; +import com.azure.cosmos.implementation.Document; +import com.azure.cosmos.implementation.HttpConstants; +import com.azure.cosmos.implementation.ImplementationBridgeHelpers; +import com.azure.cosmos.implementation.OperationType; +import com.azure.cosmos.implementation.QueryFeedOperationState; +import com.azure.cosmos.implementation.RequestOptions; +import com.azure.cosmos.implementation.ResourceType; +import com.azure.cosmos.implementation.SpyClientUnderTestFactory; +import com.azure.cosmos.implementation.TestConfigurations; +import com.azure.cosmos.implementation.TestUtils; +import com.azure.cosmos.implementation.RxDocumentClientImpl; +import com.azure.cosmos.implementation.directconnectivity.rntbd.RntbdConstants; +import com.azure.cosmos.implementation.directconnectivity.rntbd.RntbdRequest; +import com.azure.cosmos.implementation.http.HttpRequest; +import com.azure.cosmos.models.CosmosChangeFeedRequestOptions; +import com.azure.cosmos.models.CosmosClientTelemetryConfig; +import com.azure.cosmos.models.CosmosContainerProperties; +import com.azure.cosmos.models.CosmosItemRequestOptions; +import com.azure.cosmos.models.CosmosQueryRequestOptions; +import com.azure.cosmos.models.FeedRange; +import com.azure.cosmos.models.PartitionKey; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.lang.reflect.Field; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Spy-wire tests for ReadConsistencyStrategy header propagation across both transports. + * + *

Creates two spy clients that intercept at the HTTP client layer: + *

    + *
  • Gateway V1 (HTTP/1): No thin client enablement. Requests flow through + * {@link RxGatewayStoreModel}. Assertions inspect HTTP headers.
  • + *
  • Gateway V2 (HTTP/2 + thin client): {@code COSMOS.THINCLIENT_ENABLED=true} + + * {@link Http2ConnectionConfig} enabled. Requests flow through {@link ThinClientStoreModel} + * when thin client read locations are available. Assertions inspect the RNTBD binary frame + * in the HTTP body for the ReadConsistencyStrategy token (0x00FE, Byte type).
  • + *
+ * + *

V2 tests are skipped when the test account does not have thin client read locations + * (the spy client falls back to V1 silently — we detect this and skip rather than false-pass). + */ +public class GatewayReadConsistencyStrategySpyWireTest { + private static final Logger logger = LoggerFactory.getLogger(GatewayReadConsistencyStrategySpyWireTest.class); + + private static ImplementationBridgeHelpers.CosmosItemRequestOptionsHelper.CosmosItemRequestOptionsAccessor getItemOptionsAccessor() { + return ImplementationBridgeHelpers.CosmosItemRequestOptionsHelper.getCosmosItemRequestOptionsAccessor(); + } + + private static final long TIMEOUT = 60_000L; + private static final String DOCUMENT_ID = UUID.randomUUID().toString(); + + // V1 transport — HTTP/1, gateway mode, no thin client + private static final String V1 = "GatewayV1"; + // V2 transport — HTTP/2, thin client enabled + private static final String V2 = "GatewayV2"; + + private CosmosAsyncClient cosmosClient; + private CosmosAsyncDatabase database; + private CosmosAsyncContainer container; + private String databaseId; + private String containerId; + + private SpyClientUnderTestFactory.ClientUnderTest v1SpyClient; + private SpyClientUnderTestFactory.ClientUnderTest v2SpyClient; + + @BeforeClass(groups = {"thinclient"}, timeOut = TIMEOUT) + public void beforeClass() { + System.setProperty("COSMOS.THINCLIENT_ENABLED", "true"); + + cosmosClient = new CosmosClientBuilder() + .endpoint(TestConfigurations.HOST) + .key(TestConfigurations.MASTER_KEY) + .gatewayMode() + .buildAsyncClient(); + + databaseId = "ReadConsistencyStrategy-spy-" + UUID.randomUUID().toString().substring(0, 8); + containerId = "testcontainer"; + + cosmosClient.createDatabaseIfNotExists(databaseId).block(); + + CosmosContainerProperties props = new CosmosContainerProperties(containerId, "/mypk"); + container = TestSuiteBase.createCollection(cosmosClient, databaseId, props); + database = cosmosClient.getDatabase(databaseId); + + // Seed a document + ObjectNode doc = com.fasterxml.jackson.databind.node.JsonNodeFactory.instance.objectNode(); + doc.put("id", DOCUMENT_ID); + doc.put("mypk", DOCUMENT_ID); + container.createItem(doc).block(); + + // V1 spy — HTTP/1, no Http2ConnectionConfig → useThinClient = false + v1SpyClient = createSpyClient(null, false); + + // V2 spy — HTTP/2 enabled, thin client JVM flag set + v2SpyClient = createSpyClient(null, true); + + logger.info("Created spy-wire test resources: db={}, container={}", databaseId, containerId); + } + + @AfterClass(groups = {"thinclient"}, alwaysRun = true) + public void afterClass() { + if (database != null) { + try { + database.delete().block(); + } catch (Exception e) { + logger.warn("Failed to delete test database", e); + } + } + if (cosmosClient != null) { + cosmosClient.close(); + } + if (v1SpyClient != null) { + v1SpyClient.close(); + } + if (v2SpyClient != null) { + v2SpyClient.close(); + } + } + + @DataProvider(name = "transportModes") + public Object[][] transportModes() { + return new Object[][] { { V1 }, { V2 } }; + } + + // region No contention — single header set + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void requestLevelReadConsistencyStrategy_headerOnWire_consistencyLevelStripped(String mode) { + CosmosItemRequestOptions options = new CosmosItemRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + assertReadConsistencyStrategyOnWire(mode, spyFor(mode), options, "LatestCommitted", (byte) 0x03, true); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void clientLevelReadConsistencyStrategy_headerOnWire_consistencyLevelStripped(String mode) { + SpyClientUnderTestFactory.ClientUnderTest readConsistencyStrategyClient = createSpyClient(ReadConsistencyStrategy.LATEST_COMMITTED, isGatewayV2(mode)); + try { + assertReadConsistencyStrategyOnWire(mode, readConsistencyStrategyClient, new RequestOptions(), "LatestCommitted", (byte) 0x03, true); + } finally { + readConsistencyStrategyClient.close(); + } + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void defaultReadConsistencyStrategy_noReadConsistencyStrategyHeaderOnWire(String mode) { + CosmosItemRequestOptions options = new CosmosItemRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.DEFAULT); + + assertNoReadConsistencyStrategyOnWire(mode, spyFor(mode), options); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void clientLeveldefaultReadConsistencyStrategy_noReadConsistencyStrategyHeaderOnWire(String mode) { + SpyClientUnderTestFactory.ClientUnderTest defaultReadConsistencyStrategyClient = createSpyClient(ReadConsistencyStrategy.DEFAULT, isGatewayV2(mode)); + try { + assertNoReadConsistencyStrategyOnWire(mode, defaultReadConsistencyStrategyClient, new RequestOptions()); + } finally { + defaultReadConsistencyStrategyClient.close(); + } + } + + // endregion + + // region Contention — both ConsistencyLevel and ReadConsistencyStrategy present, resolution determines the winner + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void bothConsistencyLevelAndReadConsistencyStrategy_readConsistencyStrategyWins_consistencyLevelStripped(String mode) { + CosmosItemRequestOptions options = new CosmosItemRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + options.setConsistencyLevel(ConsistencyLevel.EVENTUAL); + + assertReadConsistencyStrategyOnWire(mode, spyFor(mode), options, "LatestCommitted", (byte) 0x03, true); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void requestLevelReadConsistencyStrategy_overridesClientLevelReadConsistencyStrategy(String mode) { + // Client-level ReadConsistencyStrategy = EVENTUAL (applied to every request header via builder) + // Request-level ReadConsistencyStrategy = LATEST_COMMITTED (per-operation override via requestContext) + // Resolution: request-level wins. + SpyClientUnderTestFactory.ClientUnderTest eventualReadConsistencyStrategyClient = createSpyClient(ReadConsistencyStrategy.EVENTUAL, isGatewayV2(mode)); + try { + CosmosItemRequestOptions options = new CosmosItemRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + assertReadConsistencyStrategyOnWire(mode, eventualReadConsistencyStrategyClient, options, "LatestCommitted", (byte) 0x03, true); + } finally { + eventualReadConsistencyStrategyClient.close(); + } + } + + // endregion + + // region Query operations — ReadConsistencyStrategy header propagation + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void query_requestLevelReadConsistencyStrategy_headerOnWire_consistencyLevelStripped(String mode) { + CosmosQueryRequestOptions options = new CosmosQueryRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + options.setPartitionKey(new PartitionKey(DOCUMENT_ID)); + + HttpRequest captured = executeQueryAndCapture(mode, spyFor(mode), options); + assertReadConsistencyStrategyOnCapturedRequest(mode, captured, "LatestCommitted", (byte) 0x03, true); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void query_defaultReadConsistencyStrategy_noReadConsistencyStrategyHeaderOnWire(String mode) { + CosmosQueryRequestOptions options = new CosmosQueryRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.DEFAULT); + options.setPartitionKey(new PartitionKey(DOCUMENT_ID)); + + HttpRequest captured = executeQueryAndCapture(mode, spyFor(mode), options); + assertNoReadConsistencyStrategyOnCapturedRequest(mode, captured); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void query_bothConsistencyLevelAndReadConsistencyStrategy_readConsistencyStrategyWins_consistencyLevelStripped(String mode) { + CosmosQueryRequestOptions options = new CosmosQueryRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + options.setConsistencyLevel(ConsistencyLevel.EVENTUAL); + options.setPartitionKey(new PartitionKey(DOCUMENT_ID)); + + HttpRequest captured = executeQueryAndCapture(mode, spyFor(mode), options); + assertReadConsistencyStrategyOnCapturedRequest(mode, captured, "LatestCommitted", (byte) 0x03, true); + } + + // endregion + + // region Change-feed operations — ReadConsistencyStrategy header propagation + // CosmosChangeFeedRequestOptions does not expose setConsistencyLevel, so contention is not testable here. + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void changeFeed_requestLevelReadConsistencyStrategy_headerOnWire(String mode) { + CosmosChangeFeedRequestOptions options = CosmosChangeFeedRequestOptions + .createForProcessingFromBeginning(FeedRange.forLogicalPartition(new PartitionKey(DOCUMENT_ID))); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + HttpRequest captured = executeChangeFeedAndCapture(mode, spyFor(mode), options); + // ConsistencyLevel is not user-settable on change-feed options, so don't assert strip behavior here. + assertReadConsistencyStrategyOnCapturedRequest(mode, captured, "LatestCommitted", (byte) 0x03, false); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void changeFeed_defaultReadConsistencyStrategy_noReadConsistencyStrategyHeaderOnWire(String mode) { + CosmosChangeFeedRequestOptions options = CosmosChangeFeedRequestOptions + .createForProcessingFromBeginning(FeedRange.forLogicalPartition(new PartitionKey(DOCUMENT_ID))); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.DEFAULT); + + HttpRequest captured = executeChangeFeedAndCapture(mode, spyFor(mode), options); + assertNoReadConsistencyStrategyOnCapturedRequest(mode, captured); + } + + // endregion + + // region readManyByPartitionKeys operations — ReadConsistencyStrategy header propagation + // readManyByPartitionKeys uses Query infrastructure under the hood (CosmosQueryRequestOptions + QueryFeedOperationState). + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void readMany_requestLevelReadConsistencyStrategy_headerOnWire_consistencyLevelStripped(String mode) { + CosmosQueryRequestOptions options = new CosmosQueryRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + + HttpRequest captured = executeReadManyAndCapture(mode, spyFor(mode), options); + assertReadConsistencyStrategyOnCapturedRequest(mode, captured, "LatestCommitted", (byte) 0x03, true); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void readMany_defaultReadConsistencyStrategy_noReadConsistencyStrategyHeaderOnWire(String mode) { + CosmosQueryRequestOptions options = new CosmosQueryRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.DEFAULT); + + HttpRequest captured = executeReadManyAndCapture(mode, spyFor(mode), options); + assertNoReadConsistencyStrategyOnCapturedRequest(mode, captured); + } + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT, dataProvider = "transportModes") + public void readMany_bothConsistencyLevelAndReadConsistencyStrategy_readConsistencyStrategyWins_consistencyLevelStripped(String mode) { + CosmosQueryRequestOptions options = new CosmosQueryRequestOptions(); + options.setReadConsistencyStrategy(ReadConsistencyStrategy.LATEST_COMMITTED); + options.setConsistencyLevel(ConsistencyLevel.EVENTUAL); + + HttpRequest captured = executeReadManyAndCapture(mode, spyFor(mode), options); + assertReadConsistencyStrategyOnCapturedRequest(mode, captured, "LatestCommitted", (byte) 0x03, true); + } + + // endregion + + // region Write operations — ReadConsistencyStrategy should not appear on writes (V1 only, writes don't route to thin client) + + @Test(groups = {"thinclient"}, timeOut = TIMEOUT) + public void writeWithClientLevelReadConsistencyStrategy_noReadConsistencyStrategyHeaderOnWire() { + SpyClientUnderTestFactory.ClientUnderTest readConsistencyStrategyClient = createSpyClient(ReadConsistencyStrategy.LATEST_COMMITTED, false); + try { + String writeDocId = UUID.randomUUID().toString(); + Document writeDoc = new Document(String.format( + "{ \"id\": \"%s\", \"mypk\": \"%s\" }", writeDocId, writeDocId)); + + readConsistencyStrategyClient.clearCapturedRequests(); + readConsistencyStrategyClient.createDocument(getCollectionLink(), writeDoc, null, false).block(); + + List requests = readConsistencyStrategyClient.getCapturedRequests(); + assertThat(requests).isNotEmpty(); + + HttpRequest createRequest = requests.stream() + .filter(r -> "POST".equalsIgnoreCase(r.httpMethod().toString())) + .findFirst() + .orElse(null); + assertThat(createRequest).as("Expected a document create request").isNotNull(); + + Map headers = createRequest.headers().toMap(); + assertThat(headers.containsKey(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .as("Write operations should not have ReadConsistencyStrategy header") + .isFalse(); + } finally { + readConsistencyStrategyClient.close(); + } + } + + // endregion + + // region Assertion helpers — branch on transport mode + + private void assertReadConsistencyStrategyOnWire( + String mode, + SpyClientUnderTestFactory.ClientUnderTest client, + CosmosItemRequestOptions cosmosOptions, + String expectedHeaderValue, + byte expectedRntbdByte, + boolean expectClStripped) { + + HttpRequest captured = executeReadAndCapture(mode, client, cosmosOptions); + + if (isGatewayV2(mode)) { + // Verify the request actually routed through thin client (port 10250) + assertThat(captured.uri().toString()) + .as("V2 request should target thin client proxy endpoint (port 10250)") + .contains(":10250"); + + // V2: decode RNTBD frame and inspect typed tokens + RntbdRequest rntbdRequest = decodeRntbdFrame(collectHttpBody(captured)); + Byte readConsistencyStrategyValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(readConsistencyStrategyValue) + .as("ReadConsistencyStrategy token value should be 0x%02X", expectedRntbdByte) + .isNotNull() + .isEqualTo(expectedRntbdByte); + if (expectClStripped) { + Byte clValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ConsistencyLevel); + assertThat(clValue) + .as("ConsistencyLevel RNTBD token should not be set when ReadConsistencyStrategy is active (0 = unset)") + .isEqualTo((byte) 0); + } + } else { + // V1: inspect HTTP headers + Map headers = captured.headers().toMap(); + assertThat(headers.get(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .isEqualTo(expectedHeaderValue); + if (expectClStripped) { + assertThat(headers.containsKey(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL)) + .as("ConsistencyLevel header should be stripped when ReadConsistencyStrategy is set") + .isFalse(); + } + } + } + + private void assertReadConsistencyStrategyOnWire( + String mode, + SpyClientUnderTestFactory.ClientUnderTest client, + RequestOptions requestOptions, + String expectedHeaderValue, + byte expectedRntbdByte, + boolean expectClStripped) { + + HttpRequest captured = executeReadAndCapture(mode, client, requestOptions); + + if (isGatewayV2(mode)) { + assertThat(captured.uri().toString()) + .as("V2 request should target thin client proxy endpoint (port 10250)") + .contains(":10250"); + + RntbdRequest rntbdRequest = decodeRntbdFrame(collectHttpBody(captured)); + Byte readConsistencyStrategyValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(readConsistencyStrategyValue) + .as("ReadConsistencyStrategy token value should be 0x%02X", expectedRntbdByte) + .isNotNull() + .isEqualTo(expectedRntbdByte); + if (expectClStripped) { + Byte clValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ConsistencyLevel); + assertThat(clValue) + .as("ConsistencyLevel RNTBD token should not be set when ReadConsistencyStrategy is active (0 = unset)") + .isEqualTo((byte) 0); + } + } else { + Map headers = captured.headers().toMap(); + assertThat(headers.get(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .isEqualTo(expectedHeaderValue); + if (expectClStripped) { + assertThat(headers.containsKey(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL)) + .as("ConsistencyLevel header should be stripped when ReadConsistencyStrategy is set") + .isFalse(); + } + } + } + + private void assertNoReadConsistencyStrategyOnWire(String mode, SpyClientUnderTestFactory.ClientUnderTest client, CosmosItemRequestOptions cosmosOptions) { + HttpRequest captured = executeReadAndCapture(mode, client, cosmosOptions); + + if (isGatewayV2(mode)) { + assertThat(captured.uri().toString()) + .as("V2 request should target thin client proxy endpoint (port 10250)") + .contains(":10250"); + + RntbdRequest rntbdRequest = decodeRntbdFrame(collectHttpBody(captured)); + Byte readConsistencyStrategyValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(readConsistencyStrategyValue) + .as("ReadConsistencyStrategy RNTBD token should not be set when DEFAULT (0 = unset)") + .isEqualTo((byte) 0); + } else { + Map headers = captured.headers().toMap(); + assertThat(headers.containsKey(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .as("DEFAULT ReadConsistencyStrategy should not emit a header") + .isFalse(); + } + } + + private void assertNoReadConsistencyStrategyOnWire(String mode, SpyClientUnderTestFactory.ClientUnderTest client, RequestOptions requestOptions) { + HttpRequest captured = executeReadAndCapture(mode, client, requestOptions); + + if (isGatewayV2(mode)) { + assertThat(captured.uri().toString()) + .as("V2 request should target thin client proxy endpoint (port 10250)") + .contains(":10250"); + + RntbdRequest rntbdRequest = decodeRntbdFrame(collectHttpBody(captured)); + Byte readConsistencyStrategyValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(readConsistencyStrategyValue) + .as("ReadConsistencyStrategy RNTBD token should not be set when DEFAULT (0 = unset)") + .isEqualTo((byte) 0); + } else { + Map headers = captured.headers().toMap(); + assertThat(headers.containsKey(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .as("DEFAULT ReadConsistencyStrategy should not emit a header") + .isFalse(); + } + } + + // endregion + + // region Execution + capture helpers + + private HttpRequest executeReadAndCapture(String mode, SpyClientUnderTestFactory.ClientUnderTest client, CosmosItemRequestOptions cosmosOptions) { + client.clearCapturedRequests(); + RequestOptions requestOptions = getItemOptionsAccessor().toRequestOptions(cosmosOptions); + requestOptions.setPartitionKey(new PartitionKey(DOCUMENT_ID)); + client.readDocument(getDocumentLink(), requestOptions).block(); + + List requests = client.getCapturedRequests(); + assertThat(requests).isNotEmpty(); + + HttpRequest docRequest = findDocumentReadRequest(mode, requests); + assertThat(docRequest).as("Expected a document read request").isNotNull(); + return docRequest; + } + + private HttpRequest executeReadAndCapture(String mode, SpyClientUnderTestFactory.ClientUnderTest client, RequestOptions requestOptions) { + client.clearCapturedRequests(); + requestOptions.setPartitionKey(new PartitionKey(DOCUMENT_ID)); + client.readDocument(getDocumentLink(), requestOptions).block(); + + List requests = client.getCapturedRequests(); + assertThat(requests).isNotEmpty(); + + HttpRequest docRequest = findDocumentReadRequest(mode, requests); + assertThat(docRequest).as("Expected a document read request").isNotNull(); + return docRequest; + } + + private HttpRequest executeQueryAndCapture( + String mode, + SpyClientUnderTestFactory.ClientUnderTest client, + CosmosQueryRequestOptions queryOptions) { + + QueryFeedOperationState state = TestUtils.createDummyQueryFeedOperationState( + ResourceType.Document, OperationType.Query, queryOptions, client); + client.clearCapturedRequests(); + client.queryDocuments(getCollectionLink(), "SELECT * FROM c", state, Document.class) + .blockFirst(); + + List requests = client.getCapturedRequests(); + assertThat(requests).isNotEmpty(); + + HttpRequest feedRequest = findFeedRequest(mode, requests); + assertThat(feedRequest).as("Expected a query feed request").isNotNull(); + return feedRequest; + } + + private HttpRequest executeChangeFeedAndCapture( + String mode, + SpyClientUnderTestFactory.ClientUnderTest client, + CosmosChangeFeedRequestOptions changeFeedOptions) { + + ChangeFeedOperationState state = new ChangeFeedOperationState( + cosmosClient, + "spyChangeFeed", + databaseId, + containerId, + ResourceType.Document, + OperationType.ReadFeed, + null, + changeFeedOptions, + new CosmosPagedFluxOptions()); + client.clearCapturedRequests(); + client.queryDocumentChangeFeedFromPagedFlux(getCollectionLink(), state, Document.class) + .blockFirst(); + + List requests = client.getCapturedRequests(); + assertThat(requests).isNotEmpty(); + + HttpRequest feedRequest = findFeedRequest(mode, requests); + assertThat(feedRequest).as("Expected a change-feed request").isNotNull(); + return feedRequest; + } + + private HttpRequest executeReadManyAndCapture( + String mode, + SpyClientUnderTestFactory.ClientUnderTest client, + CosmosQueryRequestOptions queryOptions) { + + QueryFeedOperationState state = TestUtils.createDummyQueryFeedOperationState( + ResourceType.Document, OperationType.Query, queryOptions, client); + client.clearCapturedRequests(); + client.readManyByPartitionKeys( + Collections.singletonList(new PartitionKey(DOCUMENT_ID)), + null, + getCollectionLink(), + state, + 1, + 1, + Document.class) + .blockFirst(); + + List requests = client.getCapturedRequests(); + assertThat(requests).isNotEmpty(); + + HttpRequest feedRequest = findFeedRequest(mode, requests); + assertThat(feedRequest).as("Expected a readMany feed request").isNotNull(); + return feedRequest; + } + + private void assertReadConsistencyStrategyOnCapturedRequest( + String mode, + HttpRequest captured, + String expectedHeaderValue, + byte expectedRntbdByte, + boolean expectClStripped) { + + if (isGatewayV2(mode)) { + assertThat(captured.uri().toString()) + .as("V2 request should target thin client proxy endpoint (port 10250)") + .contains(":10250"); + + RntbdRequest rntbdRequest = decodeRntbdFrame(collectHttpBody(captured)); + Byte readConsistencyStrategyValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(readConsistencyStrategyValue) + .as("ReadConsistencyStrategy token value should be 0x%02X", expectedRntbdByte) + .isNotNull() + .isEqualTo(expectedRntbdByte); + if (expectClStripped) { + Byte clValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ConsistencyLevel); + assertThat(clValue) + .as("ConsistencyLevel RNTBD token should not be set when ReadConsistencyStrategy is active (0 = unset)") + .isEqualTo((byte) 0); + } + } else { + Map headers = captured.headers().toMap(); + assertThat(headers.get(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .isEqualTo(expectedHeaderValue); + if (expectClStripped) { + assertThat(headers.containsKey(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL)) + .as("ConsistencyLevel header should be stripped when ReadConsistencyStrategy is set") + .isFalse(); + } + } + } + + private void assertNoReadConsistencyStrategyOnCapturedRequest(String mode, HttpRequest captured) { + if (isGatewayV2(mode)) { + assertThat(captured.uri().toString()) + .as("V2 request should target thin client proxy endpoint (port 10250)") + .contains(":10250"); + + RntbdRequest rntbdRequest = decodeRntbdFrame(collectHttpBody(captured)); + Byte readConsistencyStrategyValue = rntbdRequest.getHeader(RntbdConstants.RntbdRequestHeader.ReadConsistencyStrategy); + assertThat(readConsistencyStrategyValue) + .as("ReadConsistencyStrategy RNTBD token should not be set when DEFAULT (0 = unset)") + .isEqualTo((byte) 0); + } else { + Map headers = captured.headers().toMap(); + assertThat(headers.containsKey(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) + .as("DEFAULT ReadConsistencyStrategy should not emit a header") + .isFalse(); + } + } + + // endregion + + // region Factory + utility helpers + + private SpyClientUnderTestFactory.ClientUnderTest spyFor(String mode) { + return isGatewayV2(mode) ? v2SpyClient : v1SpyClient; + } + + private static boolean isGatewayV2(String mode) { + return V2.equals(mode); + } + + private SpyClientUnderTestFactory.ClientUnderTest createSpyClient(ReadConsistencyStrategy ReadConsistencyStrategy, boolean http2Enabled) { + ConnectionPolicy gwPolicy = new ConnectionPolicy(GatewayConnectionConfig.getDefaultConfig()); + // Explicitly pin HTTP/2 to the requested state. The default GatewayConnectionConfig + // installs a non-null Http2ConnectionConfig with enabled=null, which falls back to + // the global COSMOS.HTTP2_ENABLED system property. CI sets that property to true, so + // the "V1" spy would otherwise silently route via the thin-client path and the test's + // V1-shaped request assertions (GET /docs/{id}, POST /docs without id) would never + // match. Pin enabled=false explicitly to keep V1 deterministic regardless of the JVM + // flag, and enabled=true for V2 to match the existing intent. + gwPolicy.setHttp2ConnectionConfig(new Http2ConnectionConfig().setEnabled(http2Enabled)); + try { + SpyClientUnderTestFactory.ClientUnderTest spy = SpyClientUnderTestFactory.createClientUnderTest( + new URI(TestConfigurations.HOST), + TestConfigurations.MASTER_KEY, + gwPolicy, + ConsistencyLevel.SESSION, + ReadConsistencyStrategy, + new Configs(), + null, + true, + new CosmosClientTelemetryConfig()); + // The spy client's super(...) constructor bypasses the Builder path that initializes + // operationPolicies via Builder.withOperationPolicies(...). Without this field set, + // change-feed and other code paths that call this.operationPolicies.forEach(...) NPE. + // Initialize to an empty list so spy clients behave like default-built production clients. + initOperationPoliciesIfNull(spy); + return spy; + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } + } + + private static void initOperationPoliciesIfNull(SpyClientUnderTestFactory.ClientUnderTest spy) { + try { + Field field = RxDocumentClientImpl.class.getDeclaredField("operationPolicies"); + field.setAccessible(true); + if (field.get(spy) == null) { + field.set(spy, new ArrayList<>()); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException("Failed to initialize operationPolicies on spy client", e); + } + } + + private String getDocumentLink() { + return "dbs/" + databaseId + "/colls/" + containerId + "/docs/" + DOCUMENT_ID; + } + + private String getCollectionLink() { + return "dbs/" + databaseId + "/colls/" + containerId; + } + + private HttpRequest findDocumentReadRequest(String mode, List requests) { + for (HttpRequest request : requests) { + String uri = request.uri().toString(); + if (isGatewayV2(mode)) { + // Thin client sends all requests as POST to proxy (:10250) with RNTBD frame body + if ("POST".equalsIgnoreCase(request.httpMethod().toString()) && uri.contains(":10250")) { + return request; + } + } else { + // Gateway V1 sends document reads as GET with the document link in the URI + if ("GET".equalsIgnoreCase(request.httpMethod().toString()) && uri.contains(getDocumentLink())) { + return request; + } + } + } + return null; + } + + /** + * Finds the feed request (query / change-feed / readMany) that targets the test collection's docs endpoint. + * For V2, any request to the thin-client proxy port (:10250) qualifies (writes don't route through V2). + * For V1, the feed request hits {@code .../colls/{containerId}/docs} (without a trailing {@code /docId}). + * Query and readMany use POST; change-feed uses GET with {@code A-IM: Incremental feed}. + */ + private HttpRequest findFeedRequest(String mode, List requests) { + String docsPath = "/colls/" + containerId + "/docs"; + for (HttpRequest request : requests) { + String uri = request.uri().toString(); + if (isGatewayV2(mode)) { + if ("POST".equalsIgnoreCase(request.httpMethod().toString()) && uri.contains(":10250")) { + return request; + } + } else { + String httpMethod = request.httpMethod().toString(); + boolean isPost = "POST".equalsIgnoreCase(httpMethod); + boolean isGet = "GET".equalsIgnoreCase(httpMethod); + if (!isPost && !isGet) { + continue; + } + // Skip query-plan precursor requests: queries first POST to /colls/{id}/docs with + // x-ms-cosmos-is-query-plan-request: True (no ReadConsistencyStrategy header), + // followed by the actual data POST to the same endpoint (which carries the header). + String isQueryPlan = request.headers().toMap().get(HttpConstants.HttpHeaders.IS_QUERY_PLAN_REQUEST); + if ("True".equalsIgnoreCase(isQueryPlan)) { + continue; + } + int idx = uri.indexOf(docsPath); + if (idx < 0) { + continue; + } + // Make sure we matched the collection-docs endpoint, not a single document URI ending in /docs/{id}. + int tail = idx + docsPath.length(); + if (tail == uri.length() || uri.charAt(tail) == '?' || uri.charAt(tail) == '/' && tail + 1 == uri.length()) { + return request; + } + // Accept URIs that have the docs path but no further document id segment. + if (uri.endsWith(docsPath) || uri.endsWith(docsPath + "/")) { + return request; + } + } + } + return null; + } + + // endregion + + // region RNTBD frame inspection helpers (for V2 assertions) + + private static byte[] collectHttpBody(HttpRequest httpRequest) { + return httpRequest.body().reduce((a, b) -> { + byte[] merged = new byte[a.length + b.length]; + System.arraycopy(a, 0, merged, 0, a.length); + System.arraycopy(b, 0, merged, a.length, b.length); + return merged; + }).block(); + } + + /** + * Decodes the RNTBD binary frame and returns the typed request object. + * Uses the production decoder (RntbdRequest.decode) so token presence/absence + * is determined by the actual RNTBD wire format, not brute-force byte scanning. + * + *

The HTTP body may contain trailing payload bytes (for query / readMany operations: + * {@code [4-byte LE expectedLength][frame][headers][4-byte payload length][payload]}). + * {@code expectedLength} encodes the total of {@code length prefix + frame + headers}. + * {@link RntbdRequest#decode(ByteBuf)} computes payloadBuf size as + * {@code expectedLength - bytes consumed so far}, which is 0 when no payload is expected — + * but {@link com.azure.cosmos.implementation.directconnectivity.rntbd.RntbdRequestHeaders#decode} + * reads tokens until the buffer is exhausted (not until the headers section ends). So we must + * slice the buffer to exactly {@code expectedLength} bytes to prevent header decoding from + * walking past the headers boundary and into payload bytes. + */ + private static RntbdRequest decodeRntbdFrame(byte[] rntbdFrame) { + ByteBuf buffer = Unpooled.wrappedBuffer(rntbdFrame); + int expectedLength = buffer.getIntLE(buffer.readerIndex()); + ByteBuf sliced = buffer.slice(buffer.readerIndex(), expectedLength); + return RntbdRequest.decode(sliced); + } + + // endregion +} diff --git a/sdk/cosmos/azure-cosmos/CHANGELOG.md b/sdk/cosmos/azure-cosmos/CHANGELOG.md index 3c1f0975a420..0e674308599b 100644 --- a/sdk/cosmos/azure-cosmos/CHANGELOG.md +++ b/sdk/cosmos/azure-cosmos/CHANGELOG.md @@ -4,6 +4,7 @@ #### Features Added * Promoted the Full Fidelity Change Feed (AllVersionsAndDeletes) APIs to GA - See [PR 49283](https://github.com/Azure/azure-sdk-for-java/pull/49283) +* Enabled `ReadConsistencyStrategy` for Gateway V1 (compute gateway) and Gateway V2 (thin client proxy). Previously only supported in Direct mode. - See [PR 48787](https://github.com/Azure/azure-sdk-for-java/pull/48787) #### Breaking Changes @@ -15,6 +16,7 @@ * Replaced per-client `Schedulers.newSingle()` schedulers in `GlobalEndpointManager` and `GlobalPartitionEndpointManagerForPerPartitionCircuitBreaker` with shared `BoundedElastic` schedulers in `CosmosSchedulers` to prevent thread count from scaling linearly with client/tenant count. - See [PR 49062](https://github.com/Azure/azure-sdk-for-java/pull/49062) * Fixed a sporadic `NullPointerException` in `JsonSerializable.getWithMapping` triggered by concurrent first-time calls to `DatabaseAccount.getConsistencyPolicy()` and its sibling lazy getters (`getReplicationPolicy`, `getSystemReplicationPolicy`, `getQueryEngineConfiguration`). The fix makes `JsonSerializable.propertyBag` `final`, closing an unsafe-publication race in the lazy-initialisation pattern. - See [Issue 49256](https://github.com/Azure/azure-sdk-for-java/issues/49256) and [PR #49258](https://github.com/Azure/azure-sdk-for-java/pull/49258) * Changed 449 (`Retry With`) retries in Gateway V1 and Gateway V2 to be consistently orchestrated client-side. - See [PR 49332](https://github.com/Azure/azure-sdk-for-java/pull/49332) +* Added client-side fast-fail validation for `ReadConsistencyStrategy.GLOBAL_STRONG`: requests that specify `GLOBAL_STRONG` against an account whose default consistency is not `STRONG` are now rejected client-side with a `BadRequestException` (HTTP 400). - See [PR 48787](https://github.com/Azure/azure-sdk-for-java/pull/48787) ### 4.80.0 (2026-05-01) diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/ReadConsistencyStrategy.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/ReadConsistencyStrategy.java index f8e094a37164..786eef5dab57 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/ReadConsistencyStrategy.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/ReadConsistencyStrategy.java @@ -22,7 +22,7 @@ * in RequestOptions, CosmosClient or the default consistency level for an account unless * ReadConsistencyStrategy `DEFAULT` is used. *

- * NOTE: The ReadConsistencyStrategy is currently only working when using direct mode + * NOTE: The ReadConsistencyStrategy is honored across all connection modes. */ @Beta(value = Beta.SinceVersion.V4_69_0, warningText = Beta.PREVIEW_SUBJECT_TO_CHANGE_WARNING) public enum ReadConsistencyStrategy { diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ChangeFeedQueryImpl.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ChangeFeedQueryImpl.java index c8bca12fcbf4..ce2f4961fb1b 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ChangeFeedQueryImpl.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ChangeFeedQueryImpl.java @@ -146,20 +146,26 @@ private RxDocumentServiceRequest createDocumentServiceRequest() { if (this.options.getReadConsistencyStrategy() != null) { - String readConsistencyStrategyName = options.getReadConsistencyStrategy().toString(); - this.client.validateAndLogNonDefaultReadConsistencyStrategy(readConsistencyStrategyName); - headers.put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, readConsistencyStrategyName); + this.client.validateReadConsistencyStrategy(options.getReadConsistencyStrategy()); + + if (this.options.getReadConsistencyStrategy() != ReadConsistencyStrategy.DEFAULT) { + headers.put( + HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, + options.getReadConsistencyStrategy().toString()); + } consistencyLevelOverrideApplicable = this.options.getReadConsistencyStrategy() == ReadConsistencyStrategy.DEFAULT; } if (consistencyLevelOverrideApplicable && this.client.getReadConsistencyStrategy() != null) { - String readConsistencyStrategyName = this.client.getReadConsistencyStrategy().toString(); - this.client.validateAndLogNonDefaultReadConsistencyStrategy(readConsistencyStrategyName); - headers.put( - HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, - readConsistencyStrategyName); + this.client.validateReadConsistencyStrategy(this.client.getReadConsistencyStrategy()); + + if (this.client.getReadConsistencyStrategy() != ReadConsistencyStrategy.DEFAULT) { + headers.put( + HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, + this.client.getReadConsistencyStrategy().toString()); + } consistencyLevelOverrideApplicable = this.client.getReadConsistencyStrategy() == ReadConsistencyStrategy.DEFAULT; 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 11183bf31ac0..787b63dfb326 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 @@ -1988,16 +1988,18 @@ private static void validateResource(Resource resource) { } } - public void validateAndLogNonDefaultReadConsistencyStrategy(String readConsistencyStrategyName) { - if (this.connectionPolicy.getConnectionMode() != ConnectionMode.DIRECT - && readConsistencyStrategyName != null - && ! readConsistencyStrategyName.equalsIgnoreCase(ReadConsistencyStrategy.DEFAULT.toString())) { - - logger.warn( - "ReadConsistencyStrategy {} defined in Gateway mode. " - + "This version of the SDK only supports ReadConsistencyStrategy in DIRECT mode. " - + "This setting will be ignored.", - readConsistencyStrategyName); + public void validateReadConsistencyStrategy(ReadConsistencyStrategy readConsistencyStrategy) { + if (readConsistencyStrategy == ReadConsistencyStrategy.GLOBAL_STRONG) { + ConsistencyLevel accountConsistency = this.getDefaultConsistencyLevelOfAccount(); + if (accountConsistency != ConsistencyLevel.STRONG) { + throw new BadRequestException( + String.format( + RMResources.ReadConsistencyStrategyGlobalStrongOnlyAllowedForGlobalStrongAccount, + readConsistencyStrategy, + HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, + ConsistencyLevel.STRONG, + accountConsistency)); + } } } @@ -2029,8 +2031,12 @@ private Map getRequestHeaders(RequestOptions options, ResourceTy && operationType.isReadOnlyOperation()) { String readConsistencyStrategyName = readConsistencyStrategy.toString(); - this.validateAndLogNonDefaultReadConsistencyStrategy(readConsistencyStrategyName); + this.validateReadConsistencyStrategy(readConsistencyStrategy); headers.put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, readConsistencyStrategyName); + // Compute gateway rejects requests with both x-ms-consistency-level and + // x-ms-cosmos-read-consistency-strategy headers. When readConsistencyStrategy is set, remove + // consistency-level — readConsistencyStrategy takes precedence. + headers.remove(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL); } if (options == null) { @@ -2074,13 +2080,20 @@ private Map getRequestHeaders(RequestOptions options, ResourceTy && operationType.isReadOnlyOperation()) { String readConsistencyStrategyName = options.getReadConsistencyStrategy().toString(); - this.validateAndLogNonDefaultReadConsistencyStrategy(readConsistencyStrategyName); + this.validateReadConsistencyStrategy(options.getReadConsistencyStrategy()); headers.put( HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, readConsistencyStrategyName); + // Compute gateway rejects requests with both x-ms-consistency-level and + // x-ms-cosmos-read-consistency-strategy headers. When readConsistencyStrategy is set, remove + // consistency-level — readConsistencyStrategy takes precedence. + headers.remove(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL); } - if (options.getConsistencyLevel() != null) { + if (options.getConsistencyLevel() != null + && !headers.containsKey(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY)) { + // Only set ConsistencyLevel when ReadConsistencyStrategy is NOT already present. + // readConsistencyStrategy takes precedence — setting both causes gateway rejection. headers.put(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL, options.getConsistencyLevel().toString()); } @@ -5498,8 +5511,8 @@ public ConsistencyLevel getConsistencyLevel() { } @Override - public void validateAndLogNonDefaultReadConsistencyStrategy(String readConsistencyStrategyName) { - RxDocumentClientImpl.this.validateAndLogNonDefaultReadConsistencyStrategy(readConsistencyStrategyName); + public void validateReadConsistencyStrategy(ReadConsistencyStrategy readConsistencyStrategy) { + RxDocumentClientImpl.this.validateReadConsistencyStrategy(readConsistencyStrategy); } @Override diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentServiceRequest.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentServiceRequest.java index dee68a1512b1..03f27156f62f 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentServiceRequest.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxDocumentServiceRequest.java @@ -1050,8 +1050,18 @@ public byte[] getContentAsByteArray() { @Override public RxDocumentServiceRequest clone() { + // Deep-copy headers so availability-strategy / hedging clones do not share + // the same HashMap reference with their parent. The shared-reference pattern + // was previously safe-by-convention (assumption: no mutation after clone), + // but resolveEffectiveConsistencyHeaders mutates the map on every request, + // which races with concurrent hedged clones. A racing put can trigger HashMap + // resize and corrupt the map (lost inserts, null reads of unrelated keys). + // Same defensive-copy pattern already used for requestContext and + // faultInjectionRequestContext below. + Map sourceHeaders = this.getHeaders(); + Map clonedHeaders = sourceHeaders != null ? new HashMap<>(sourceHeaders) : null; RxDocumentServiceRequest rxDocumentServiceRequest = RxDocumentServiceRequest.create(this.clientContext, this.getOperationType(), - this.resourceId, this.isNameBased, this.getResourceType(),this.getHeaders()); + this.resourceId, this.isNameBased, this.getResourceType(), clonedHeaders); rxDocumentServiceRequest.setPartitionKeyInternal(this.getPartitionKeyInternal()); rxDocumentServiceRequest.setContentBytes(this.contentAsByteArray); rxDocumentServiceRequest.setContinuation(this.getContinuation()); diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxGatewayStoreModel.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxGatewayStoreModel.java index e2431570e0c6..a3cd0c1f0dcc 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxGatewayStoreModel.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/RxGatewayStoreModel.java @@ -12,7 +12,6 @@ import com.azure.cosmos.implementation.caches.RxPartitionKeyRangeCache; import com.azure.cosmos.implementation.directconnectivity.GatewayServiceConfigurationReader; import com.azure.cosmos.implementation.directconnectivity.HttpUtils; -import com.azure.cosmos.implementation.directconnectivity.RequestHelper; import com.azure.cosmos.implementation.directconnectivity.StoreResponse; import com.azure.cosmos.implementation.directconnectivity.Uri; import com.azure.cosmos.implementation.directconnectivity.WebExceptionUtility; @@ -344,9 +343,123 @@ public Mono performRequestInternal(RxDocumentServiceR }); } + /** + * Resolves contention between the {@code x-ms-consistency-level} and + * {@code x-ms-cosmos-read-consistency-strategy} request headers so that, after this method runs, + * the request carries at most one of the two. + * + *

Both Gateway transports reject requests that carry both headers simultaneously:

+ *
    + *
  • Gateway V1 (HTTP) — rejects with HTTP 400.
  • + *
  • Gateway V2 / thin-client proxy (RNTBD) — rejects with HTTP 400.
  • + *
+ * + *

Priority rules (highest to lowest):

+ *
    + *
  1. Request-level {@code ReadConsistencyStrategy} on {@code requestContext} + * beats the client-level value carried in the header.
  2. + *
  3. {@code ReadConsistencyStrategy} beats {@code ConsistencyLevel} — + * when a non-{@code DEFAULT} {@code ReadConsistencyStrategy} is effective, the + * {@code ConsistencyLevel} header is stripped.
  4. + *
  5. {@code DEFAULT} {@code ReadConsistencyStrategy} is transparent — + * the {@code ConsistencyLevel} header is left untouched.
  6. + *
+ * + *

Once resolved, GW V1 serializes the surviving header as HTTP; GW V2 + * ({@code ThinClientStoreModel}) encodes it as RNTBD.

+ * + *

Java SDK-specific behavior. When a non-{@code DEFAULT} {@code ReadConsistencyStrategy} + * is effective, the {@code ConsistencyLevel} header is proactively stripped from the request. + * The .NET SDK does not strip this header. This divergence is intentional — + * it prevents the dual-header HTTP 400 rejection described above, which the compute gateway and + * thin-client proxy enforce.

+ * + *

Thread safety. Availability-strategy clones each receive their own deep-copied + * {@link java.util.HashMap} of headers (see {@link RxDocumentServiceRequest#clone()}), so + * concurrent hedged requests do not race on the same map instance. The mutations performed + * here ({@code remove(CONSISTENCY_LEVEL)} and {@code put(READ_CONSISTENCY_STRATEGY, ...)}) + * are therefore safe by isolation — each clone mutates its own map.

+ * + * @param request the in-flight request whose consistency headers are normalized in place. + */ + private void resolveEffectiveConsistencyHeaders(RxDocumentServiceRequest request) { + resolveEffectiveConsistencyHeaders( + request.getHeaders(), + request.requestContext != null ? request.requestContext.readConsistencyStrategy : null); + } + + /** + * Core normalization logic — {@code public static} for direct unit testing from cross-package + * test classes (e.g. {@code RntbdReadConsistencyStrategyHeaderTests} in {@code azure-cosmos-tests}). + * Avoids test drift from duplicated simulation logic. + * + *

Behavior is identical to the instance overload {@link #resolveEffectiveConsistencyHeaders(RxDocumentServiceRequest)}; + * see that method's javadoc for the priority rules.

+ */ + public static void resolveEffectiveConsistencyHeaders( + Map headers, + ReadConsistencyStrategy requestContextReadConsistencyStrategy) { + + ReadConsistencyStrategy effectiveRCS = + resolveEffectiveReadConsistencyStrategy(headers, requestContextReadConsistencyStrategy); + + if (effectiveRCS != null) { + // Non-DEFAULT RCS wins — strip ConsistencyLevel to prevent dual-header rejection + headers.remove(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL); + // Ensure the RCS header is set (requestContext-level may not have been written to headers yet) + headers.put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, effectiveRCS.toString()); + } else { + // No effective RCS — clean up any DEFAULT sentinel header, let ConsistencyLevel govern + String rcsHeader = headers.get(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY); + if (!Strings.isNullOrEmpty(rcsHeader)) { + headers.remove(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY); + } + } + } + + /** + * Resolves the effective non-DEFAULT {@link ReadConsistencyStrategy} for a request. + * + *

Priority: requestContext RCS (request-level) > header RCS (client-level). + * Returns {@code null} when no non-DEFAULT RCS is active — callers should fall through + * to {@code ConsistencyLevel} or account-default logic.

+ * + *

This is the single source of truth for "which RCS wins?" and is consumed by both + * {@link #resolveEffectiveConsistencyHeaders(Map, ReadConsistencyStrategy)} (header mutation) and + * {@link #isEffectiveSessionConsistency} (session-token decision).

+ */ + private static ReadConsistencyStrategy resolveEffectiveReadConsistencyStrategy( + Map headers, + ReadConsistencyStrategy requestContextReadConsistencyStrategy) { + + // Request-level (requestContext) takes priority over client-level (header) + if (requestContextReadConsistencyStrategy != null + && requestContextReadConsistencyStrategy != ReadConsistencyStrategy.DEFAULT) { + return requestContextReadConsistencyStrategy; + } + + // Client-level from header + String rcsHeader = headers.get(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY); + if (!Strings.isNullOrEmpty(rcsHeader)) { + for (ReadConsistencyStrategy candidate : ReadConsistencyStrategy.values()) { + if (candidate != ReadConsistencyStrategy.DEFAULT + && candidate.toString().equals(rcsHeader)) { + return candidate; + } + } + } + + return null; + } + private Mono performRequestInternalCore(RxDocumentServiceRequest request, URI requestUri) { try { + // Canonicalize consistency headers before wire serialization. + // Both GW V1 (HTTP) and GW V2 (RNTBD via ThinClientStoreModel) read from + // request.getHeaders() — this ensures only the winning header reaches the wire. + resolveEffectiveConsistencyHeaders(request); + HttpRequest httpRequest = request .getEffectiveHttpTransportSerializer(this) .wrapInHttpRequest(request, requestUri); @@ -1044,6 +1157,33 @@ private Mono resolvePartitionKeyRangeByPkRangeIdCore( }); } + /** + * Determines if the effective consistency for this request is Session — needed by + * {@link #applySessionToken} to decide whether to attach/remove session tokens. + *

+ * Pure read — no side effects, no header mutation. + * Uses {@link #resolveEffectiveReadConsistencyStrategy} for the RCS priority chain, + * then falls through to ConsistencyLevel / account-default if no RCS is active. + */ + private boolean isEffectiveSessionConsistency(RxDocumentServiceRequest request) { + ReadConsistencyStrategy effectiveRCS = resolveEffectiveReadConsistencyStrategy( + request.getHeaders(), + request.requestContext != null ? request.requestContext.readConsistencyStrategy : null); + + if (effectiveRCS != null) { + return effectiveRCS == ReadConsistencyStrategy.SESSION; + } + + // No RCS active — fall through to ConsistencyLevel header + String clHeader = request.getHeaders().get(HttpConstants.HttpHeaders.CONSISTENCY_LEVEL); + if (!Strings.isNullOrEmpty(clHeader)) { + return ConsistencyLevel.SESSION.toString().equalsIgnoreCase(clHeader); + } + + // Fall back to account default + return this.gatewayServiceConfigurationReader.getDefaultConsistencyLevel() == ConsistencyLevel.SESSION; + } + private Mono applySessionToken(RxDocumentServiceRequest request) { Map headers = request.getHeaders(); Objects.requireNonNull(headers, "RxDocumentServiceRequest::headers is required and cannot be null"); @@ -1056,8 +1196,11 @@ private Mono applySessionToken(RxDocumentServiceRequest request) { return Mono.empty(); } - boolean sessionConsistency = (RequestHelper.getReadConsistencyStrategyToUse(this.gatewayServiceConfigurationReader, - request) == ReadConsistencyStrategy.SESSION); + // Determine if the effective consistency is Session — needed to decide whether to + // attach/remove session tokens. This is a pure read with no side-effects; it does NOT + // call RequestHelper.getReadConsistencyStrategyToUse() which mutates x-ms-consistency-level + // (a Direct-mode telemetry concern that is harmful in Gateway mode). + boolean sessionConsistency = isEffectiveSessionConsistency(request); if (!Strings.isNullOrEmpty(request.getHeaders().get(HttpConstants.HttpHeaders.SESSION_TOKEN))) { if (!sessionConsistency || diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdConstants.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdConstants.java index ae596cec0494..15b4b50df559 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdConstants.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdConstants.java @@ -27,6 +27,24 @@ public static class RntbdHealthCheckResults { public static final String SuccessValue = "Success"; } + public enum RntbdReadConsistencyStrategy { + + Eventual((byte) 0x01), + Session((byte) 0x02), + LatestCommitted((byte) 0x03), + GlobalStrong((byte) 0x04); + + private final byte id; + + RntbdReadConsistencyStrategy(final byte id) { + this.id = id; + } + + public byte id() { + return this.id; + } + } + public enum RntbdConsistencyLevel { Strong((byte) 0x00), @@ -600,7 +618,8 @@ public enum RntbdRequestHeader implements RntbdHeader { PopulateQueryAdvice((short) 0x00DA, RntbdTokenType.Byte, false), ThroughputBucket((short)0x00DB, RntbdTokenType.Byte, false), WorkloadId((short)0x00DC, RntbdTokenType.Byte, false), - HubRegionProcessingOnly((short)0x00EF, RntbdTokenType.Byte , false); + HubRegionProcessingOnly((short)0x00EF, RntbdTokenType.Byte , false), + ReadConsistencyStrategy((short)0x00FE, RntbdTokenType.Byte, false); public static final List thinClientHeadersInOrderList = Arrays.asList( EffectivePartitionKey, diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdRequestHeaders.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdRequestHeaders.java index 653860cd7c30..38de8021c44f 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdRequestHeaders.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/directconnectivity/rntbd/RntbdRequestHeaders.java @@ -5,6 +5,7 @@ import com.azure.cosmos.BridgeInternal; import com.azure.cosmos.ConsistencyLevel; +import com.azure.cosmos.ReadConsistencyStrategy; import com.azure.cosmos.implementation.ContentSerializationFormat; import com.azure.cosmos.implementation.EnumerationDirection; import com.azure.cosmos.implementation.FanoutOperationState; @@ -142,6 +143,7 @@ private static ImplementationBridgeHelpers.PriorityLevelHelper.PriorityLevelAcce this.addThroughputBucket(headers); this.addPopulateQueryAdvice(headers); this.addHubRegionProcessingOnly(headers); + this.addReadConsistencyStrategy(headers); this.addWorkloadId(headers); // Normal headers (Strings, Ints, Longs, etc.) @@ -834,6 +836,48 @@ private void addHubRegionProcessingOnly(final Map headers) { } } + private RntbdToken getReadConsistencyStrategy() { + return this.get(RntbdRequestHeader.ReadConsistencyStrategy); + } + + private void addReadConsistencyStrategy(final Map headers) { + final String value = headers.get(HttpHeaders.READ_CONSISTENCY_STRATEGY); + + if (StringUtils.isNotEmpty(value)) { + + final ReadConsistencyStrategy strategy = ImplementationBridgeHelpers + .ReadConsistencyStrategyHelper + .getReadConsistencyStrategyAccessor() + .createFromServiceSerializedFormat(value); + + if (strategy == null) { + throw new IllegalArgumentException( + "Unknown ReadConsistencyStrategy value '" + value + "' — cannot encode in RNTBD frame"); + } + + switch (strategy) { + case EVENTUAL: + this.getReadConsistencyStrategy().setValue(RntbdConstants.RntbdReadConsistencyStrategy.Eventual.id()); + break; + case SESSION: + this.getReadConsistencyStrategy().setValue(RntbdConstants.RntbdReadConsistencyStrategy.Session.id()); + break; + case LATEST_COMMITTED: + this.getReadConsistencyStrategy().setValue(RntbdConstants.RntbdReadConsistencyStrategy.LatestCommitted.id()); + break; + case GLOBAL_STRONG: + this.getReadConsistencyStrategy().setValue(RntbdConstants.RntbdReadConsistencyStrategy.GlobalStrong.id()); + break; + case DEFAULT: + // DEFAULT means use the account/client default — skip writing to the RNTBD frame + break; + default: + assert false; + break; + } + } + } + private void addWorkloadId(final Map headers) { final String value = headers.get(HttpHeaders.WORKLOAD_ID); diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/DocumentQueryExecutionContextBase.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/DocumentQueryExecutionContextBase.java index 519dfaecebaf..4e8d26112316 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/DocumentQueryExecutionContextBase.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/DocumentQueryExecutionContextBase.java @@ -300,20 +300,26 @@ public Map createCommonHeadersAsync(CosmosQueryRequestOptions co if (cosmosQueryRequestOptions.getReadConsistencyStrategy() != null) { - String readConsistencyStrategyName = cosmosQueryRequestOptions.getReadConsistencyStrategy().toString(); - this.client.validateAndLogNonDefaultReadConsistencyStrategy(readConsistencyStrategyName); - requestHeaders.put(HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, readConsistencyStrategyName); + this.client.validateReadConsistencyStrategy(cosmosQueryRequestOptions.getReadConsistencyStrategy()); + + if (cosmosQueryRequestOptions.getReadConsistencyStrategy() != ReadConsistencyStrategy.DEFAULT) { + requestHeaders.put( + HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, + cosmosQueryRequestOptions.getReadConsistencyStrategy().toString()); + } consistencyLevelOverrideApplicable = cosmosQueryRequestOptions.getReadConsistencyStrategy() == ReadConsistencyStrategy.DEFAULT; } if (consistencyLevelOverrideApplicable && this.client.getReadConsistencyStrategy() != null) { - String readConsistencyStrategyName = this.client.getReadConsistencyStrategy().toString(); - this.client.validateAndLogNonDefaultReadConsistencyStrategy(readConsistencyStrategyName); - requestHeaders.put( - HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, - readConsistencyStrategyName); + this.client.validateReadConsistencyStrategy(this.client.getReadConsistencyStrategy()); + + if (this.client.getReadConsistencyStrategy() != ReadConsistencyStrategy.DEFAULT) { + requestHeaders.put( + HttpConstants.HttpHeaders.READ_CONSISTENCY_STRATEGY, + this.client.getReadConsistencyStrategy().toString()); + } consistencyLevelOverrideApplicable = this.client.getReadConsistencyStrategy() == ReadConsistencyStrategy.DEFAULT; diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/IDocumentQueryClient.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/IDocumentQueryClient.java index 8555efc5487a..91679d40490e 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/IDocumentQueryClient.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/query/IDocumentQueryClient.java @@ -74,7 +74,7 @@ Mono executeFeedOperationWithAvailabilityStrategy( ConsistencyLevel getConsistencyLevel(); - void validateAndLogNonDefaultReadConsistencyStrategy(String readConsistencyStrategyName); + void validateReadConsistencyStrategy(ReadConsistencyStrategy readConsistencyStrategy); ///

/// A client query compatibility mode when making query request. diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosChangeFeedRequestOptions.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosChangeFeedRequestOptions.java index dba5a536ddb8..e02fc7ed3f2b 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosChangeFeedRequestOptions.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosChangeFeedRequestOptions.java @@ -106,6 +106,9 @@ public ReadConsistencyStrategy getReadConsistencyStrategy() { * level specified when constructing the CosmosClient instance via CosmosClientBuilder.consistencyLevel * is not SESSION then session token capturing also needs to be enabled by calling * CosmosClientBuilder:sessionCapturingOverrideEnabled(true) explicitly. + *

Honored across Direct, Gateway V1 (compute gateway), and Gateway V2 (thin client proxy) connection modes. + * {@code GLOBAL_STRONG} is rejected client-side with a {@link com.azure.cosmos.CosmosException} (HTTP 400) + * when the account's default consistency is not {@link com.azure.cosmos.ConsistencyLevel#STRONG}. Such failures must NOT be retried.

* * @param readConsistencyStrategy the consistency level. * @return the request options. diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosItemRequestOptions.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosItemRequestOptions.java index a9db1aa9b4c2..65aec17aac0a 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosItemRequestOptions.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosItemRequestOptions.java @@ -206,6 +206,9 @@ public CosmosItemRequestOptions setConsistencyLevel(ConsistencyLevel consistency * CosmosClientBuilder:sessionCapturingOverrideEnabled(true) explicitly. * NOTE: The `setConsistencyLevel` value specified is ignored when `setReadConsistencyStrategy` is used unless * `DEFAULT` is specified. + *

Honored across Direct, Gateway V1 (compute gateway), and Gateway V2 (thin client proxy) connection modes. + * {@code GLOBAL_STRONG} is rejected client-side with a {@link com.azure.cosmos.CosmosException} (HTTP 400) + * when the account's default consistency is not {@link ConsistencyLevel#STRONG}. Such failures must NOT be retried.

* * @param readConsistencyStrategy the consistency level. * @return the CosmosItemRequestOptions. diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosQueryRequestOptions.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosQueryRequestOptions.java index 84dcca9743c1..3d35d7308d56 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosQueryRequestOptions.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosQueryRequestOptions.java @@ -99,6 +99,9 @@ public CosmosQueryRequestOptions setConsistencyLevel(ConsistencyLevel consistenc * CosmosClientBuilder:sessionCapturingOverrideEnabled(true) explicitly. * NOTE: The `setConsistencyLevel` value specified is ignored when `setReadConsistencyStrategy` is used unless * `DEFAULT` is specified. + *

Honored across Direct, Gateway V1 (compute gateway), and Gateway V2 (thin client proxy) connection modes. + * {@code GLOBAL_STRONG} is rejected client-side with a {@link com.azure.cosmos.CosmosException} (HTTP 400) + * when the account's default consistency is not {@link com.azure.cosmos.ConsistencyLevel#STRONG}. Such failures must NOT be retried.

* * @param readConsistencyStrategy the consistency level. * @return the CosmosQueryRequestOptions. diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosReadManyByPartitionKeysRequestOptions.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosReadManyByPartitionKeysRequestOptions.java index 5f779dd6c8ad..a4503a06e766 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosReadManyByPartitionKeysRequestOptions.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosReadManyByPartitionKeysRequestOptions.java @@ -150,6 +150,9 @@ public ReadConsistencyStrategy getReadConsistencyStrategy() { /** * Sets the read consistency strategy required for the request. + *

Honored across Direct, Gateway V1 (compute gateway), and Gateway V2 (thin client proxy) connection modes. + * {@code GLOBAL_STRONG} is rejected client-side with a {@link com.azure.cosmos.CosmosException} (HTTP 400) + * when the account's default consistency is not {@link com.azure.cosmos.ConsistencyLevel#STRONG}. Such failures must NOT be retried.

* * @param readConsistencyStrategy the read consistency strategy. * @return the {@link CosmosReadManyByPartitionKeysRequestOptions} for fluent chaining. diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosReadManyRequestOptions.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosReadManyRequestOptions.java index 69b016bafc87..9a4f2f6f3e79 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosReadManyRequestOptions.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/models/CosmosReadManyRequestOptions.java @@ -88,6 +88,9 @@ public CosmosReadManyRequestOptions setConsistencyLevel(ConsistencyLevel consist * CosmosClientBuilder:sessionCapturingOverrideEnabled(true) explicitly. * NOTE: The `setConsistencyLevel` value specified is ignored when `setReadConsistencyStrategy` is used unless * `DEFAULT` is specified. + *

Honored across Direct, Gateway V1 (compute gateway), and Gateway V2 (thin client proxy) connection modes. + * {@code GLOBAL_STRONG} is rejected client-side with a {@link com.azure.cosmos.CosmosException} (HTTP 400) + * when the account's default consistency is not {@link com.azure.cosmos.ConsistencyLevel#STRONG}. Such failures must NOT be retried.

* * @param readConsistencyStrategy the consistency level. * @return the CosmosReadManyRequestOptions.