diff --git a/sdk/cosmos/azure-cosmos-tests/pom.xml b/sdk/cosmos/azure-cosmos-tests/pom.xml index efa44981f72d..952bfeeb9ed1 100644 --- a/sdk/cosmos/azure-cosmos-tests/pom.xml +++ b/sdk/cosmos/azure-cosmos-tests/pom.xml @@ -857,6 +857,67 @@ Licensed under the MIT License. + + manual-http-network-fault + + manual-http-network-fault + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.3 + + + src/test/resources/manual-http-network-fault-testng.xml + + + true + true + true + true + 1 + 2 + + + + + + + + + manual-http-network-fault-compute + + manual-http-network-fault + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.5.3 + + + src/test/resources/manual-http-network-fault-testng.xml + + + true + false + true + true + 1 + 2 + + + + + + fi-thinclient-multi-region diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosDiagnosticsTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosDiagnosticsTest.java index a51a26d2d49b..43bebbe52b52 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosDiagnosticsTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosDiagnosticsTest.java @@ -21,6 +21,7 @@ import com.azure.cosmos.implementation.RxStoreModel; import com.azure.cosmos.implementation.TestConfigurations; import com.azure.cosmos.implementation.UserAgentContainer; +import com.azure.cosmos.implementation.UserAgentFeatureFlags; import com.azure.cosmos.implementation.Utils; import com.azure.cosmos.implementation.clienttelemetry.ClientTelemetry; import com.azure.cosmos.implementation.directconnectivity.GatewayAddressCache; @@ -1978,8 +1979,17 @@ private void validateChannelAcquisitionContext(CosmosDiagnostics diagnostics, bo } private String generateHttp2OptedInUserAgentIfRequired(String userAgent) { + // Mirrors RxDocumentClientImpl.addUserAgentSuffix + UserAgentContainer.setFeatureEnabledFlagsAsSuffix: + // when HTTP/2 is enabled, the Http2 bit is set; when PING keepalive is also effectively enabled + // (kill-switch on AND positive interval), the Http2PingHealth bit is OR'd in. + // Tests here do not override Http2ConnectionConfig.setEnabled(...) so the per-client override branch + // in addUserAgentSuffix is a no-op for this helper. if (Configs.isHttp2Enabled()) { - userAgent = userAgent + "|F10"; + int featureValue = UserAgentFeatureFlags.Http2.getValue(); + if (Configs.isHttp2PingHealthEnabled() && Configs.getHttp2PingIntervalInSeconds() > 0) { + featureValue |= UserAgentFeatureFlags.Http2PingHealth.getValue(); + } + userAgent = userAgent + "|F" + Integer.toHexString(featureValue).toUpperCase(Locale.ROOT); } return userAgent; diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/UserAgentSuffixTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/UserAgentSuffixTest.java index 116eb5b72e0e..ceb5a9044682 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/UserAgentSuffixTest.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/UserAgentSuffixTest.java @@ -7,6 +7,7 @@ package com.azure.cosmos; import com.azure.cosmos.implementation.Configs; +import com.azure.cosmos.implementation.UserAgentFeatureFlags; import com.azure.cosmos.models.CosmosContainerResponse; import com.azure.cosmos.rx.TestSuiteBase; import org.testng.ITestContext; @@ -15,6 +16,8 @@ import org.testng.annotations.Factory; import org.testng.annotations.Test; +import java.util.Locale; + import static org.assertj.core.api.Assertions.assertThat; public class UserAgentSuffixTest extends TestSuiteBase { @@ -115,8 +118,15 @@ public void userAgentSuffixWithWhitespaceAndAsciiSpecialChars() { private void validateUserAgentSuffix(String actualUserAgent, String expectedUserAgentSuffix) { + // Mirrors RxDocumentClientImpl.addUserAgentSuffix + UserAgentContainer.setFeatureEnabledFlagsAsSuffix: + // when HTTP/2 is enabled, the Http2 bit is set; when PING keepalive is also effectively enabled + // (kill-switch on AND positive interval), the Http2PingHealth bit is OR'd in. if (Configs.isHttp2Enabled()) { - expectedUserAgentSuffix = expectedUserAgentSuffix + "|F10"; + int featureValue = UserAgentFeatureFlags.Http2.getValue(); + if (Configs.isHttp2PingHealthEnabled() && Configs.getHttp2PingIntervalInSeconds() > 0) { + featureValue |= UserAgentFeatureFlags.Http2PingHealth.getValue(); + } + expectedUserAgentSuffix = expectedUserAgentSuffix + "|F" + Integer.toHexString(featureValue).toUpperCase(Locale.ROOT); } assertThat(actualUserAgent).endsWith(expectedUserAgentSuffix); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/faultinjection/Http2PingKeepaliveTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/faultinjection/Http2PingKeepaliveTest.java new file mode 100644 index 000000000000..99a5f25a793d --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/faultinjection/Http2PingKeepaliveTest.java @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.cosmos.faultinjection; + +import com.azure.cosmos.CosmosAsyncClient; +import com.azure.cosmos.CosmosAsyncContainer; +import com.azure.cosmos.CosmosClientBuilder; +import com.azure.cosmos.CosmosDiagnostics; +import com.azure.cosmos.ConsistencyLevel; +import com.azure.cosmos.GatewayConnectionConfig; +import com.azure.cosmos.TestObject; +import com.azure.cosmos.implementation.TestConfigurations; +import com.azure.cosmos.implementation.Utils; +import com.azure.cosmos.models.CosmosItemResponse; +import com.azure.cosmos.models.PartitionKey; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testng.annotations.AfterClass; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.URI; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * HTTP/2 PING keepalive handler test. + *

+ * Uses {@code iptables DROP} to blackhole PING ACKs, verifying the handler closes + * the broken connection after consecutive PING timeouts and a subsequent request + * uses a new connection. + *

+ * Run in Docker with {@code --cap-add=NET_ADMIN} (group: {@code manual-http-network-fault}). + *

+ * Two transports exercise the same handler -- pick one per pipeline run via system properties: + *

+ * Override the port explicitly with {@code -DHTTP2_PING_TEST_PORT=} if needed. + * {@code COSMOS.HTTP2_ENABLED=true} is always set by the test. + */ +public class Http2PingKeepaliveTest extends FaultInjectionTestBase { + + private static final Logger logger = LoggerFactory.getLogger(Http2PingKeepaliveTest.class); + private static final long TEST_TIMEOUT = 120_000; // 2 minutes + + // sudo prefix: empty when running as root (Docker), "sudo " on CI VMs + private static final String SUDO = "root".equals(System.getProperty("user.name")) ? "" : "sudo "; + + // Transport selection -- defaults to thin-client for back-compat with the existing pipeline. + private static final boolean THIN_CLIENT_ENABLED = + Boolean.parseBoolean(System.getProperty("COSMOS.THINCLIENT_ENABLED", "true")); + private static final int H2_PORT = + Integer.getInteger("HTTP2_PING_TEST_PORT", THIN_CLIENT_ENABLED ? 10250 : 443); + + private CosmosAsyncClient client; + private CosmosAsyncContainer cosmosAsyncContainer; + private TestObject seedItem; + + public Http2PingKeepaliveTest() { + super(new CosmosClientBuilder() + .endpoint(TestConfigurations.HOST) + .key(TestConfigurations.MASTER_KEY) + .consistencyLevel(ConsistencyLevel.SESSION) + .gatewayMode()); + } + + @BeforeClass(groups = {"manual-http-network-fault"}, timeOut = 120_000) + public void beforeClass() { + // HTTP/2 must be enabled before the client is constructed. THINCLIENT_ENABLED is + // set externally (Maven profile or -D) so a single test class covers both transports + // across two pipeline runs. + System.setProperty("COSMOS.HTTP2_ENABLED", "true"); + logger.info("Transport selected: thinClient={}, h2Port={}", THIN_CLIENT_ENABLED, H2_PORT); + + this.client = getClientBuilder().buildAsyncClient(); + this.cosmosAsyncContainer = getSharedMultiPartitionCosmosContainerWithIdAsPartitionKey(this.client); + + // Seed item for reads + this.seedItem = TestObject.create(); + this.cosmosAsyncContainer.createItem(this.seedItem).block(); + logger.info("Seed item created: {}", this.seedItem.getId()); + } + + @AfterClass(groups = {"manual-http-network-fault"}, timeOut = 60_000, alwaysRun = true) + public void afterClass() { + safeClose(this.client); + System.clearProperty("COSMOS.HTTP2_ENABLED"); + } + + @BeforeMethod(groups = {"manual-http-network-fault"}) + public void beforeMethod(Method method) { + logger.info("=== START: {} ===", method.getName()); + } + + @AfterMethod(groups = {"manual-http-network-fault"}) + public void afterMethod(Method method) { + logger.info("=== END: {} ===", method.getName()); + } + + /** + * End-to-end H3 verification: a read request issued WHILE the connection is being + * blackholed (iptables DROP on the thin-client / regional-gateway port) is held in + * flight while {@link com.azure.cosmos.implementation.http.Http2PingHandler} + * closes the parent channel after consecutive PING ACK timeouts. The closed + * channel propagates a typed + * {@link com.azure.cosmos.implementation.http.Http2PingTimeoutChannelClosedException} + * to the in-flight child stream, which {@code RxGatewayStoreModel} stamps with + * {@code SubStatusCodes.GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED (10006)}. The + * test then asserts that {@code ClientRetryPolicy} retried the request and the + * eventual success landed on the SAME regional gateway endpoint, proving that + * the PING-driven close did NOT trigger {@code markEndpointUnavailableForRead} + * or cross-region failover. + *

+ * Without this fix, the same exception would have been classified as a generic + * {@code GATEWAY_ENDPOINT_UNAVAILABLE (10001)}, refresh-location would have run, + * the regional endpoint would have been marked down, and the retry would have + * either failed or landed on a different region depending on preferred-regions. + *

+ * Requires Docker with {@code --cap-add=NET_ADMIN} or Linux with sudo. + */ + @Test(groups = {"manual-http-network-fault"}, timeOut = TEST_TIMEOUT) + public void inFlightReadRetriesInSameRegionAfterPingClose() throws Exception { + // Short interval + timeout for fast detection + System.setProperty("COSMOS.HTTP2_PING_INTERVAL_IN_SECONDS", "1"); + System.setProperty("COSMOS.HTTP2_PING_TIMEOUT_IN_SECONDS", "2"); + System.setProperty("COSMOS.HTTP2_PING_HEALTH_ENABLED", "true"); + // Override threshold to 2 for faster test (default=5 aligned with Rust SDK) + System.setProperty("COSMOS.HTTP2_PING_FAILURE_THRESHOLD", "2"); + + // Lifted out of the try so the finally-block cleanup can reach it whether or not + // the iptables ADD ran -- finally needs the exact -D form of whatever -A we installed. + String iptablesDelete = null; + Thread iptablesRemovalThread = null; + + try { + safeClose(this.client); + + // Single-connection pool via Http2ConnectionConfig API + GatewayConnectionConfig gwConfig = new GatewayConnectionConfig(); + gwConfig.getHttp2ConnectionConfig() + .setEnabled(true) + .setMaxConnectionPoolSize(1) + .setMinConnectionPoolSize(1); + + this.client = new CosmosClientBuilder() + .endpoint(TestConfigurations.HOST) + .key(TestConfigurations.MASTER_KEY) + .consistencyLevel(ConsistencyLevel.SESSION) + .gatewayMode(gwConfig) + .buildAsyncClient(); + this.cosmosAsyncContainer = getSharedMultiPartitionCosmosContainerWithIdAsPartitionKey(this.client); + + // Warm-up read -- establish the H2 connection and capture diagnostics so we can + // (a) prove H2 was actually negotiated, (b) discover the regional endpoint host + // for IP-scoped iptables targeting on the Compute / port-443 path, and + // (c) capture the initial channel-id and endpoint host to compare against + // the recovery response. + CosmosItemResponse warmup = this.cosmosAsyncContainer.readItem( + seedItem.getId(), new PartitionKey(seedItem.getId()), TestObject.class).block(); + assertThat(warmup).isNotNull(); + assertThat(warmup.getStatusCode()).isEqualTo(200); + + CosmosDiagnostics warmupDiag = warmup.getDiagnostics(); + String diagStr = warmupDiag.toString(); + assertThat(diagStr) + .as("Warm-up connection must negotiate H2 (thinClient=%s). " + + "If false, the account does not expose an H2-capable Gateway V2 endpoint " + + "and this test cannot exercise the PING handler.", THIN_CLIENT_ENABLED) + .contains("\"isHttp2\":true"); + + String initialChannelId = extractParentChannelId(warmupDiag); + String warmupEndpointHost = extractEndpointHost(warmupDiag); + logger.info("Warm-up: parentChannelId={}, endpointHost={}", initialChannelId, warmupEndpointHost); + + // iptables rule: port-only for thin-client (10250 is exclusive to thin-client traffic); + // destination-IP + port for Compute (port 443 is shared with everything else in the JVM, + // so we must scope to the regional gateway's resolved address). + String iptablesAdd; + if (THIN_CLIENT_ENABLED) { + iptablesAdd = String.format( + "%siptables -A OUTPUT -p tcp --dport %d -j DROP", SUDO, H2_PORT); + iptablesDelete = String.format( + "%siptables -D OUTPUT -p tcp --dport %d -j DROP", SUDO, H2_PORT); + } else { + String regionalIp = InetAddress.getByName(warmupEndpointHost).getHostAddress(); + logger.info("Resolved {} -> {} (Compute variant uses IP-scoped DROP)", + warmupEndpointHost, regionalIp); + iptablesAdd = String.format( + "%siptables -A OUTPUT -p tcp -d %s --dport %d -j DROP", + SUDO, regionalIp, H2_PORT); + iptablesDelete = String.format( + "%siptables -D OUTPUT -p tcp -d %s --dport %d -j DROP", + SUDO, regionalIp, H2_PORT); + } + + logger.info("Installing iptables DROP rule: {}", iptablesAdd); + execCommand(iptablesAdd); + + // Schedule iptables removal in a background thread. The in-flight read below + // will block on this rule lifting; ClientRetryPolicy retries the failed + // PING-close attempt with bounded backoff (max 120 retries; see + // Configs.DEFAULT_CLIENT_ENDPOINT_FAILOVER_MAX_RETRY_COUNT), and the first + // retry attempt that lands AFTER the rule is removed succeeds on a brand + // new TCP connection to the SAME regional gateway endpoint. + // + // 20s window picked to comfortably cover: + // * 4s for Http2PingHandler to close channel-1 (interval=1s + timeout=2s + // for each of the 2 PING failures) + // * 2-3 retry attempts, each capped by netty CONNECT_TIMEOUT_MILLIS + // (~5s for thin-client per ReactorNettyClient.send) when SYN-DROPped + final String iptablesDeleteRef = iptablesDelete; + iptablesRemovalThread = new Thread(() -> { + try { + Thread.sleep(20_000); + logger.info("Background thread removing iptables DROP rule: {}", iptablesDeleteRef); + execCommand(iptablesDeleteRef); + } catch (Exception e) { + logger.error("Failed to remove iptables in background thread", e); + } + }, "iptables-removal-thread"); + iptablesRemovalThread.setDaemon(true); + iptablesRemovalThread.start(); + + // Issue the read WHILE iptables DROP is active. Timeline: + // t=0: read sent on channel-1, DROPped by iptables on the wire + // t=~4s: Http2PingHandler detects 2 consecutive PING ACK timeouts + // (interval=1s, timeout=2s, threshold=2) and closes channel-1 + // t=~4s: Http2PingCloseRewrapHandler fires the typed exception on the + // in-flight child stream; RxGatewayStoreModel stamps subStatus + // 10006 (GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED); + // ClientRetryPolicy.shouldRetry routes via the H3 branch + // (shouldRetryOnGatewayTimeout, NO endpoint mark-down) + // t=4-20s: retry attempts open new TCP connections that SYN-DROP and + // time out after ~5s each (thin-client CONNECT_TIMEOUT_MILLIS) + // t=20s: background thread removes iptables; next retry's SYN succeeds, + // H2 negotiates, the read succeeds on channel-2 against the + // SAME regional gateway endpoint + logger.info("Issuing readItem while iptables DROP is active..."); + long startNanos = System.nanoTime(); + CosmosItemResponse response = this.cosmosAsyncContainer.readItem( + seedItem.getId(), new PartitionKey(seedItem.getId()), TestObject.class).block(); + long elapsedMs = (System.nanoTime() - startNanos) / 1_000_000; + + // Wait for the iptables removal thread to finish (it should already be done) + iptablesRemovalThread.join(5_000); + // Background thread already removed the rule; clear so the finally-block doesn't retry. + iptablesDelete = null; + + assertThat(response).isNotNull(); + assertThat(response.getStatusCode()).isEqualTo(200); + + CosmosDiagnostics recoveryDiag = response.getDiagnostics(); + String recoveryDiagStr = recoveryDiag.toString(); + String recoveryChannelId = extractParentChannelId(recoveryDiag); + String recoveryEndpointHost = extractEndpointHost(recoveryDiag); + + logger.info("RESULT: elapsedMs={}, thinClient={}, port={}, " + + "initialChannel={}, recoveryChannel={}, " + + "warmupEndpoint={}, recoveryEndpoint={}, " + + "SAME_REGION={}, DIFFERENT_CONNECTION={}", + elapsedMs, THIN_CLIENT_ENABLED, H2_PORT, + initialChannelId, recoveryChannelId, + warmupEndpointHost, recoveryEndpointHost, + warmupEndpointHost.equals(recoveryEndpointHost), + !initialChannelId.equals(recoveryChannelId)); + logger.info("Recovery diagnostics: {}", recoveryDiagStr); + + // Assertion 1: channel-1 was actually closed; recovery used a new TCP connection. + assertThat(recoveryChannelId) + .as("After PING timeout (thinClient=%s, port=%d), the handler should have " + + "closed the connection. The recovery request must use a new connection.", + THIN_CLIENT_ENABLED, H2_PORT) + .isNotEqualTo(initialChannelId); + + // Assertion 2 (the H3 invariant we're proving): recovery landed on the SAME + // regional gateway. If ClientRetryPolicy had treated the PING-close as a + // regional outage, refreshLocation + markEndpointUnavailableForRead would + // have fired and the retry would have either failed (single-region account) + // or landed on a different region (multi-region account). + assertThat(recoveryEndpointHost) + .as("ClientRetryPolicy must NOT trigger a region failover for PING-driven " + + "channel close. Recovery endpoint should match warm-up endpoint.") + .isEqualTo(warmupEndpointHost); + + // Assertion 3: the H3 retry branch was actually exercised. Without this guard, + // a hypothetical scenario where the warm-up's connection survived and the + // in-flight read never failed (or fell through some other retry path) would + // silently pass. We require evidence in diagnostics that the PING-close + // sub-status code was stamped on at least one failed attempt. + assertThat(recoveryDiagStr) + .as("Diagnostics must record sub-status 10006 (GATEWAY_HTTP2_PING_TIMEOUT_" + + "CHANNEL_CLOSED) on at least one failed attempt, proving " + + "ClientRetryPolicy's H3 branch executed.") + .containsAnyOf("10006", "GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED", + "Http2PingTimeoutChannelClosedException"); + + logger.info("H3 verified: PING-driven close was retried in-region without endpoint mark-down"); + } finally { + // Safety: if the background removal thread is still alive (didn't get to sleep + // through 20s, or the test interrupted early), interrupt it and let the + // captured -D form below handle cleanup synchronously. + if (iptablesRemovalThread != null && iptablesRemovalThread.isAlive()) { + iptablesRemovalThread.interrupt(); + } + // Safety: if we installed an iptables rule and didn't manage to remove it above + // (e.g., assertion failed before the background thread removed it), best-effort + // remove it now. The exact -D form was captured when we built -A. + if (iptablesDelete != null) { + try { + execCommand(iptablesDelete); + } catch (Exception ignored) {} + } + + System.clearProperty("COSMOS.HTTP2_PING_INTERVAL_IN_SECONDS"); + System.clearProperty("COSMOS.HTTP2_PING_TIMEOUT_IN_SECONDS"); + System.clearProperty("COSMOS.HTTP2_PING_HEALTH_ENABLED"); + System.clearProperty("COSMOS.HTTP2_PING_FAILURE_THRESHOLD"); + } + } + + /** + * Pulls the regional gateway hostname out of the first {@code gatewayStatisticsList[]} + * entry whose {@code endpoint} URI exposes a host. Used by the Compute / port-443 + * path to scope the iptables DROP rule to a single destination IP -- a port-only + * rule on :443 would also kill every other outbound TLS connection in the JVM. + */ + private String extractEndpointHost(CosmosDiagnostics diagnostics) throws JsonProcessingException { + ObjectNode node = (ObjectNode) Utils.getSimpleObjectMapper().readTree(diagnostics.toString()); + JsonNode gwStats = node.get("gatewayStatisticsList"); + if (gwStats != null && gwStats.isArray()) { + for (JsonNode stat : gwStats) { + if (stat.has("endpoint")) { + String endpoint = stat.get("endpoint").asText(); + try { + String host = URI.create(endpoint).getHost(); + if (host != null && !host.isEmpty()) { + return host; + } + } catch (IllegalArgumentException ignored) { + // Bad URI -- fall through to the next stat entry. + } + } + } + } + throw new AssertionError("Could not extract endpoint host from diagnostics: " + diagnostics); + } + + /** + * Returns the parentChannelId of the FINAL (successful) attempt in the + * gatewayStatisticsList. We iterate from the end because the recovery + * response's diagnostics records every retry attempt: the PING-closed + * channel comes first (subStatus 10006), connection-timeout attempts in + * the middle have no parentChannelId at all (subStatus 10001), and the + * successful 200 attempt (which is the channel we want to compare against + * the warm-up channel) is last. For the warm-up response with a single + * gatewayStatistics entry, this is equivalent to picking the first. + *

+ * Parses the diagnostics JSON rather than substring-scanning toString() + * so that a future change to JSON formatting can't silently break the + * test. + */ + private String extractParentChannelId(CosmosDiagnostics diagnostics) throws JsonProcessingException { + ObjectNode node = (ObjectNode) Utils.getSimpleObjectMapper().readTree(diagnostics.toString()); + JsonNode gwStats = node.get("gatewayStatisticsList"); + if (gwStats != null && gwStats.isArray()) { + for (int i = gwStats.size() - 1; i >= 0; i--) { + JsonNode stat = gwStats.get(i); + if (stat.has("parentChannelId")) { + String id = stat.get("parentChannelId").asText(); + if (id != null && !id.isEmpty() && !"null".equals(id)) { + return id; + } + } + } + } + throw new AssertionError("Could not extract parentChannelId from diagnostics: " + diagnostics); + } + + /** + * Runs a shell command and throws on non-zero exit. The test must fail loudly + * if iptables setup fails (e.g., missing NET_ADMIN capability), rather than + * silently continuing without network fault injection and reporting a + * misleading assertion failure downstream. Cleanup-only callers in + * {@code finally} blocks can swallow the exception locally. + */ + private static void execCommand(String command) throws Exception { + Process p = Runtime.getRuntime().exec(new String[]{"bash", "-c", command}); + int exit = p.waitFor(); + if (exit != 0) { + java.io.BufferedReader reader = new java.io.BufferedReader( + new java.io.InputStreamReader(p.getErrorStream())); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + String stderr = sb.toString().trim(); + logger.warn("Command '{}' exited with code {}: {}", command, exit, stderr); + throw new RuntimeException("Command failed (exit=" + exit + "): " + command + " -- " + stderr); + } + } +} diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ClientRetryPolicyHttp2PingCloseTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ClientRetryPolicyHttp2PingCloseTest.java new file mode 100644 index 000000000000..acc4a2e834dc --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/ClientRetryPolicyHttp2PingCloseTest.java @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.cosmos.implementation; + +import com.azure.cosmos.BridgeInternal; +import com.azure.cosmos.CosmosException; +import com.azure.cosmos.ThrottlingRetryOptions; +import com.azure.cosmos.implementation.http.Http2PingTimeoutChannelClosedException; +import com.azure.cosmos.implementation.perPartitionAutomaticFailover.GlobalPartitionEndpointManagerForPerPartitionAutomaticFailover; +import com.azure.cosmos.implementation.perPartitionCircuitBreaker.GlobalPartitionEndpointManagerForPerPartitionCircuitBreaker; +import com.azure.cosmos.implementation.routing.RegionalRoutingContext; +import org.mockito.Mockito; +import org.testng.annotations.Test; +import reactor.core.publisher.Mono; + +import java.net.URI; + +import static com.azure.cosmos.implementation.TestUtils.mockDiagnosticsClientContext; + +/** + * Verifies the {@link ClientRetryPolicy} H3 branch that handles channel + * closures driven by {@code Http2PingHandler} consecutive PING ACK timeouts + * (sub-status {@code GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED} = 10006). + *

+ * Invariant under test: a PING-driven local transport failure MUST NOT cause + * {@code markEndpointUnavailableForRead} or {@code markEndpointUnavailableForWrite} + * to be invoked on {@link GlobalEndpointManager}. The remote gateway is not + * known to be unhealthy; we route via {@code shouldRetryOnGatewayTimeout} + * (bounded same-region retry for safely-retriable reads; noRetry for writes) + * instead of the GATEWAY_ENDPOINT_UNAVAILABLE path that does mark the + * endpoint down. + */ +public class ClientRetryPolicyHttp2PingCloseTest { + + private static final int ITERATIONS = 10; + + @Test(groups = "unit") + public void pingTimeoutClose_onRead_retriesInRegionWithoutEndpointMarkDown() throws Exception { + ThrottlingRetryOptions retryOptions = new ThrottlingRetryOptions(); + GlobalEndpointManager endpointManager = Mockito.mock(GlobalEndpointManager.class); + GlobalPartitionEndpointManagerForPerPartitionCircuitBreaker globalPartitionEndpointManagerForPerPartitionCircuitBreaker + = Mockito.mock(GlobalPartitionEndpointManagerForPerPartitionCircuitBreaker.class); + GlobalPartitionEndpointManagerForPerPartitionAutomaticFailover globalPartitionEndpointManagerForPerPartitionAutomaticFailover + = Mockito.mock(GlobalPartitionEndpointManagerForPerPartitionAutomaticFailover.class); + + Mockito.doReturn(new RegionalRoutingContext(new URI("http://localhost"))) + .when(endpointManager).resolveServiceEndpoint(Mockito.any(RxDocumentServiceRequest.class)); + Mockito.doReturn(Mono.empty()).when(endpointManager).refreshLocationAsync(Mockito.eq(null), Mockito.eq(false)); + Mockito.doReturn(2).when(endpointManager).getPreferredLocationCount(); + + ClientRetryPolicy clientRetryPolicy = new ClientRetryPolicy( + mockDiagnosticsClientContext(), + endpointManager, + true, + retryOptions, + globalPartitionEndpointManagerForPerPartitionCircuitBreaker, + globalPartitionEndpointManagerForPerPartitionAutomaticFailover, + false); + + CosmosException cosmosException = buildPingTimeoutCloseCosmosException(); + + RxDocumentServiceRequest dsr = RxDocumentServiceRequest.createFromName(mockDiagnosticsClientContext(), + OperationType.Read, "/dbs/db/colls/col/docs/docId", ResourceType.Document); + dsr.requestContext = new DocumentServiceRequestContext(); + clientRetryPolicy.onBeforeSendRequest(dsr); + + for (int i = 0; i < ITERATIONS; i++) { + Mono shouldRetry = clientRetryPolicy.shouldRetry(cosmosException); + + // For Read requests the H3 branch routes to shouldRetryOnGatewayTimeout's + // canPerformCrossRegionRetryOnGatewayReadTimeout=true arm, which returns + // retryAfter(endpointFailoverRetryIntervalInMs) until failoverRetryCount + // exceeds endpointFailoverMaxRetryCount (default 120). Within ITERATIONS + // we expect every call to retry. + ClientRetryPolicyTest.validateSuccess(shouldRetry, ShouldRetryValidator.builder() + .nullException() + .shouldRetry(true) + .build()); + + // The critical invariant: PING-driven close MUST NOT mark the regional + // endpoint unavailable. shouldRetryOnGatewayTimeout never calls + // markEndpointUnavailableFor*, so these counts stay at zero across + // every iteration. + Mockito.verify(endpointManager, Mockito.times(0)).markEndpointUnavailableForRead(Mockito.any()); + Mockito.verify(endpointManager, Mockito.times(0)).markEndpointUnavailableForWrite(Mockito.any()); + } + } + + @Test(groups = "unit") + public void pingTimeoutClose_onWrite_doesNotRetryAndDoesNotMarkDown() throws Exception { + ThrottlingRetryOptions retryOptions = new ThrottlingRetryOptions(); + GlobalEndpointManager endpointManager = Mockito.mock(GlobalEndpointManager.class); + GlobalPartitionEndpointManagerForPerPartitionCircuitBreaker globalPartitionEndpointManagerForPerPartitionCircuitBreaker + = Mockito.mock(GlobalPartitionEndpointManagerForPerPartitionCircuitBreaker.class); + GlobalPartitionEndpointManagerForPerPartitionAutomaticFailover globalPartitionEndpointManagerForPerPartitionAutomaticFailover + = Mockito.mock(GlobalPartitionEndpointManagerForPerPartitionAutomaticFailover.class); + + Mockito.doReturn(new RegionalRoutingContext(new URI("http://localhost"))) + .when(endpointManager).resolveServiceEndpoint(Mockito.any(RxDocumentServiceRequest.class)); + Mockito.doReturn(Mono.empty()).when(endpointManager).refreshLocationAsync(Mockito.eq(null), Mockito.eq(false)); + + ClientRetryPolicy clientRetryPolicy = new ClientRetryPolicy( + mockDiagnosticsClientContext(), + endpointManager, + true, + retryOptions, + globalPartitionEndpointManagerForPerPartitionCircuitBreaker, + globalPartitionEndpointManagerForPerPartitionAutomaticFailover, + false); + + CosmosException cosmosException = buildPingTimeoutCloseCosmosException(); + + RxDocumentServiceRequest dsr = RxDocumentServiceRequest.createFromName(mockDiagnosticsClientContext(), + OperationType.Create, "/dbs/db/colls/col/docs/docId", ResourceType.Document); + dsr.requestContext = new DocumentServiceRequestContext(); + clientRetryPolicy.onBeforeSendRequest(dsr); + + for (int i = 0; i < ITERATIONS; i++) { + Mono shouldRetry = clientRetryPolicy.shouldRetry(cosmosException); + + // For Create requests canRequestToGatewayBeSafelyRetriedOnReadTimeout + // returns false (request.isReadOnly() == false), so the H3 branch + // falls through shouldRetryOnGatewayTimeout to NO_RETRY (PPAF mock + // returns false by default). + ClientRetryPolicyTest.validateSuccess(shouldRetry, ShouldRetryValidator.builder() + .nullException() + .shouldRetry(false) + .build()); + + // Same invariant as the read case: no region mark-down on a + // local-transport PING-driven failure, regardless of write semantics. + Mockito.verify(endpointManager, Mockito.times(0)).markEndpointUnavailableForRead(Mockito.any()); + Mockito.verify(endpointManager, Mockito.times(0)).markEndpointUnavailableForWrite(Mockito.any()); + } + } + + private static CosmosException buildPingTimeoutCloseCosmosException() { + // Http2PingTimeoutChannelClosedException extends ClosedChannelException so + // WebExceptionUtility.isNetworkFailure(...) returns true (gate 1 of H3). + // BridgeInternal.createCosmosException wraps it as the inner exception so + // Utils.as(e, CosmosException.class) yields a non-null clientException + // (gate 2). Setting the sub-status code to 10006 satisfies gate 3 and + // routes execution into the H3 branch at ClientRetryPolicy.java:142. + Http2PingTimeoutChannelClosedException pingClose = + new Http2PingTimeoutChannelClosedException("ping ack timeout (test)", null); + CosmosException cosmosException = BridgeInternal.createCosmosException( + null, + HttpConstants.StatusCodes.SERVICE_UNAVAILABLE, + pingClose); + BridgeInternal.setSubStatusCode(cosmosException, + HttpConstants.SubStatusCodes.GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED); + return cosmosException; + } +} diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/http/Http2PingHandlerTest.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/http/Http2PingHandlerTest.java new file mode 100644 index 000000000000..004cde861d87 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/implementation/http/Http2PingHandlerTest.java @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.cosmos.implementation.http; + +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOutboundHandlerAdapter; +import io.netty.channel.ChannelPromise; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http2.DefaultHttp2PingFrame; +import io.netty.util.ReferenceCountUtil; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link Http2PingHandler}. + *

+ * Covers state transitions that do not require advancing real time -- the handler's + * timeout / threshold logic reads {@code System.nanoTime()}, so the tests sidestep + * the clock by pre-setting {@code pingOutstandingSinceNanos} (or + * {@code lastActivityNanos}) via reflection: + *

+ * The interval-driven scheduling itself (that {@code maybeSendPing} is fired by the + * event loop every {@code pingIntervalNanos}) is exercised by the integration test + * {@code Http2PingKeepaliveTest} under Docker with real network fault injection. + */ +public class Http2PingHandlerTest { + + @Test(groups = "unit") + public void ackWithMatchingPayload_resetsState() throws Exception { + Http2PingHandler handler = new Http2PingHandler(1, 1, 3); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + // Simulate an outstanding PING #5 that recorded one failure. + setField(handler, "pingsSent", 5); + setField(handler, "pingOutstandingSinceNanos", System.nanoTime()); + setField(handler, "consecutiveFailures", 1); + + channel.writeInbound(new DefaultHttp2PingFrame(5L, true)); + + assertThat((long) getField(handler, "pingOutstandingSinceNanos")).isZero(); + assertThat((int) getField(handler, "consecutiveFailures")).isZero(); + + channel.finishAndReleaseAll(); + } + + @Test(groups = "unit") + public void ackWithMismatchedPayload_doesNotResetState() throws Exception { + Http2PingHandler handler = new Http2PingHandler(1, 1, 3); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + long outstandingAt = System.nanoTime(); + setField(handler, "pingsSent", 5); + setField(handler, "pingOutstandingSinceNanos", outstandingAt); + setField(handler, "consecutiveFailures", 2); + + // Late ACK for an earlier PING #3 -- must NOT clear the failure counter + // that is tracking the currently outstanding PING #5. + channel.writeInbound(new DefaultHttp2PingFrame(3L, true)); + + assertThat((long) getField(handler, "pingOutstandingSinceNanos")).isEqualTo(outstandingAt); + assertThat((int) getField(handler, "consecutiveFailures")).isEqualTo(2); + + channel.finishAndReleaseAll(); + } + + @Test(groups = "unit") + public void ackAfterTimeoutCleared_doesNotResetState() throws Exception { + // Reproduces the race fixed by the `pingOutstandingSinceNanos != 0` guard in + // channelRead: PING #5 is sent (pingsSent=5), timeout fires and sets + // pingOutstandingSinceNanos=0 + consecutiveFailures=1, then a late ACK for #5 + // arrives BEFORE the next PING is sent (so pingsSent is still 5). Payload alone + // matches; the outstanding-flag guard is what prevents masking the failure. + Http2PingHandler handler = new Http2PingHandler(1, 1, 3); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + setField(handler, "pingsSent", 5); + setField(handler, "pingOutstandingSinceNanos", 0L); + setField(handler, "consecutiveFailures", 1); + + channel.writeInbound(new DefaultHttp2PingFrame(5L, true)); + + assertThat((long) getField(handler, "pingOutstandingSinceNanos")).isZero(); + assertThat((int) getField(handler, "consecutiveFailures")).isEqualTo(1); + + channel.finishAndReleaseAll(); + } + + @Test(groups = "unit") + public void installIfAbsent_isIdempotent() { + EmbeddedChannel channel = new EmbeddedChannel(); + + Http2PingHandler.installIfAbsent(channel, 1, 1, 3); + Http2PingHandler.installIfAbsent(channel, 1, 1, 3); + + long count = channel.pipeline().toMap().values().stream() + .filter(h -> h instanceof Http2PingHandler) + .count(); + assertThat(count).isEqualTo(1); + + channel.finishAndReleaseAll(); + } + + @Test(groups = "unit") + public void constructor_clampsNonPositiveValues() throws Exception { + Http2PingHandler handler = new Http2PingHandler(-5, 0, -1); + + assertThat((long) getField(handler, "pingIntervalNanos")).isEqualTo(TimeUnit.SECONDS.toNanos(1)); + assertThat((long) getField(handler, "pingTimeoutNanos")).isEqualTo(TimeUnit.SECONDS.toNanos(1)); + assertThat((int) getField(handler, "failureThreshold")).isEqualTo(1); + } + + @Test(groups = "unit") + public void killSwitchOff_clearsOutstandingPingState() throws Exception { + // M1: When Configs.isHttp2PingHealthEnabled() flips to false, the next maybeSendPing + // tick must clear any in-flight PING bookkeeping. Otherwise toggling the kill-switch + // back on after a long dormant window would charge a spurious timeout from a stale + // pingOutstandingSinceNanos that has been "outstanding" for hours. + final String prop = "COSMOS.HTTP2_PING_HEALTH_ENABLED"; + final String prior = System.getProperty(prop); + try { + Http2PingHandler handler = new Http2PingHandler(1, 1, 3); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + // Simulate an in-flight PING with a partial failure already accumulated. + setField(handler, "pingOutstandingSinceNanos", System.nanoTime()); + setField(handler, "consecutiveFailures", 2); + + // Flip kill-switch OFF and trigger the periodic tick. + System.setProperty(prop, "false"); + ChannelHandlerContext ctx = channel.pipeline().firstContext(); + invokeMaybeSendPing(handler, ctx); + + assertThat((long) getField(handler, "pingOutstandingSinceNanos")).isZero(); + assertThat((int) getField(handler, "consecutiveFailures")).isZero(); + + // Timer must still be alive so re-enabling resumes PINGing on the same connection. + assertThat((Object) getField(handler, "pingTask")).isNotNull(); + + channel.finishAndReleaseAll(); + } finally { + if (prior == null) { + System.clearProperty(prop); + } else { + System.setProperty(prop, prior); + } + } + } + + @Test(groups = "unit") + public void channelInactive_cancelsPingTask() throws Exception { + // M2: channelInactive must cancel the scheduled PING task. Otherwise the timer + // would keep firing on a dead connection until GC reclaims the handler. + Http2PingHandler handler = new Http2PingHandler(1, 1, 3); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + ScheduledFuture scheduled = (ScheduledFuture) getField(handler, "pingTask"); + assertThat((Object) scheduled).isNotNull(); + assertThat(scheduled.isCancelled()).isFalse(); + + // Closing the EmbeddedChannel fires channelInactive on the handler. + channel.close().syncUninterruptibly(); + + assertThat(scheduled.isCancelled()).isTrue(); + assertThat((Object) getField(handler, "pingTask")).isNull(); + + channel.finishAndReleaseAll(); + } + + @Test(groups = "unit") + public void writeFailure_incrementsConsecutiveFailuresAndClosesAtThreshold() throws Exception { + // Reviewer-flagged invariant (PR #49095): a failed PING write must count as a + // failed health probe. Otherwise a channel stuck in a state where writes always + // fail (e.g. H2 codec rejecting frames, stalled flow-control, queued + // ClosedChannelException not yet propagated) but channel.isActive() stays true + // would loop on every tick without ever reaching the close threshold. + final int threshold = 2; + Http2PingHandler handler = new Http2PingHandler(1, 1, threshold); + EmbeddedChannel channel = new EmbeddedChannel(handler); + + // Insert an outbound handler at HEAD that fails every write. Outbound writes + // from Http2PingHandler flow head-ward, so this intercepts them and fails the + // promise synchronously -- producing the same listener-on-failure path the + // handler would see if a real H2 codec rejected the PING frame. + channel.pipeline().addFirst("failOnWrite", new ChannelOutboundHandlerAdapter() { + @Override + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + ReferenceCountUtil.release(msg); + promise.setFailure(new IOException("simulated write failure")); + } + }); + + ChannelHandlerContext ctx = channel.pipeline().context(handler); + long longAgo = System.nanoTime() - TimeUnit.SECONDS.toNanos(60); + + // First tick: force idle -> PING send attempt -> write fails. + // Expect consecutiveFailures=1, channel still open, task still scheduled. + setField(handler, "lastActivityNanos", longAgo); + invokeMaybeSendPing(handler, ctx); + channel.runPendingTasks(); // run the write + listener on the embedded event loop + + assertThat((int) getField(handler, "consecutiveFailures")).isEqualTo(1); + assertThat((long) getField(handler, "pingOutstandingSinceNanos")).isZero(); + assertThat(channel.isOpen()).isTrue(); + assertThat((Object) getField(handler, "pingTask")).isNotNull(); + + // Second tick: write fails again -> threshold reached -> task cancelled, channel closed. + setField(handler, "lastActivityNanos", longAgo); + invokeMaybeSendPing(handler, ctx); + channel.runPendingTasks(); + + assertThat((int) getField(handler, "consecutiveFailures")).isEqualTo(threshold); + assertThat((Object) getField(handler, "pingTask")).isNull(); + assertThat(channel.isOpen()).isFalse(); + + channel.finishAndReleaseAll(); + } + + @Test(groups = "unit") + public void ackTimeout_incrementsConsecutiveFailuresAndClosesAtThreshold() throws Exception { + // Reviewer-flagged invariant (PR #49095): the primary operational PING-failure + // path -- a PING was sent, no ACK arrived within pingTimeoutNanos, and after + // `failureThreshold` consecutive such timeouts the channel must be closed. + // Time is sidestepped by pre-setting pingOutstandingSinceNanos to a value + // older than pingTimeoutNanos so maybeSendPing enters the timeout branch on + // each invocation. + final int threshold = 2; + Http2PingHandler handler = new Http2PingHandler(1, 1, threshold); // interval=1s, timeout=1s + EmbeddedChannel channel = new EmbeddedChannel(handler); + ChannelHandlerContext ctx = channel.pipeline().context(handler); + + long pastTimeout = System.nanoTime() - TimeUnit.SECONDS.toNanos(5); + + // First tick: outstanding PING has aged past timeout -> failures=1, channel still open. + setField(handler, "pingsSent", 1); + setField(handler, "pingOutstandingSinceNanos", pastTimeout); + invokeMaybeSendPing(handler, ctx); + channel.runPendingTasks(); + + assertThat((int) getField(handler, "consecutiveFailures")).isEqualTo(1); + assertThat((long) getField(handler, "pingOutstandingSinceNanos")).isZero(); + assertThat(channel.isOpen()).isTrue(); + assertThat((Object) getField(handler, "pingTask")).isNotNull(); + + // Second tick: simulate another outstanding PING that has aged past timeout + // -> failures=threshold -> task cancelled, channel closed. + setField(handler, "pingsSent", 2); + setField(handler, "pingOutstandingSinceNanos", pastTimeout); + invokeMaybeSendPing(handler, ctx); + channel.runPendingTasks(); + + assertThat((int) getField(handler, "consecutiveFailures")).isEqualTo(threshold); + assertThat((long) getField(handler, "pingOutstandingSinceNanos")).isZero(); + assertThat((Object) getField(handler, "pingTask")).isNull(); + assertThat(channel.isOpen()).isFalse(); + + channel.finishAndReleaseAll(); + } + + private static void invokeMaybeSendPing(Http2PingHandler handler, ChannelHandlerContext ctx) throws Exception { + Method m = Http2PingHandler.class.getDeclaredMethod("maybeSendPing", ChannelHandlerContext.class); + m.setAccessible(true); + m.invoke(handler, ctx); + } + + private static Object getField(Object target, String name) throws Exception { + Field f = Http2PingHandler.class.getDeclaredField(name); + f.setAccessible(true); + return f.get(target); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field f = Http2PingHandler.class.getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } +} 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 index 5a2390408e20..99fc00b69f6f 100644 --- 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 @@ -513,7 +513,7 @@ private HttpRequest executeQueryAndCapture( CosmosQueryRequestOptions queryOptions) { QueryFeedOperationState state = TestUtils.createDummyQueryFeedOperationState( - ResourceType.Document, OperationType.Query, queryOptions, client); + ResourceType.Document, OperationType.Query, queryOptions, cosmosClient); client.clearCapturedRequests(); client.queryDocuments(getCollectionLink(), "SELECT * FROM c", state, Document.class) .blockFirst(); @@ -559,7 +559,7 @@ private HttpRequest executeReadManyAndCapture( CosmosQueryRequestOptions queryOptions) { QueryFeedOperationState state = TestUtils.createDummyQueryFeedOperationState( - ResourceType.Document, OperationType.Query, queryOptions, client); + ResourceType.Document, OperationType.Query, queryOptions, cosmosClient); client.clearCapturedRequests(); client.readManyByPartitionKeys( Collections.singletonList(new PartitionKey(DOCUMENT_ID)), diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/TestSuiteBase.java b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/TestSuiteBase.java index 0e84dde4c2cf..66b6e6314ad0 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/TestSuiteBase.java +++ b/sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/rx/TestSuiteBase.java @@ -302,7 +302,7 @@ public CosmosAsyncDatabase getDatabase(String id) { @BeforeSuite(groups = {"thinclient", "fast", "long", "direct", "multi-region", "multi-master", "flaky-multi-master", "emulator", "emulator-vnext", "split", "query", "cfp-split", "circuit-breaker-misc-gateway", "circuit-breaker-misc-direct", - "circuit-breaker-read-all-read-many", "fi-multi-master", "long-emulator", "fi-thinclient-multi-region", "fi-thinclient-multi-master", "multi-region-strong"}, timeOut = SUITE_SETUP_TIMEOUT) + "circuit-breaker-read-all-read-many", "fi-multi-master", "long-emulator", "fi-thinclient-multi-region", "fi-thinclient-multi-master", "multi-region-strong", "manual-http-network-fault"}, timeOut = SUITE_SETUP_TIMEOUT) public void beforeSuite() { logger.info("beforeSuite Started"); @@ -353,7 +353,7 @@ private static DocumentCollection getInternalDocumentCollection(CosmosAsyncConta @AfterSuite(groups = {"thinclient", "fast", "long", "direct", "multi-region", "multi-master", "flaky-multi-master", "emulator", "split", "query", "cfp-split", "circuit-breaker-misc-gateway", "circuit-breaker-misc-direct", - "circuit-breaker-read-all-read-many", "fi-multi-master", "long-emulator", "fi-thinclient-multi-region", "fi-thinclient-multi-master", "multi-region-strong"}, timeOut = SUITE_SHUTDOWN_TIMEOUT) + "circuit-breaker-read-all-read-many", "fi-multi-master", "long-emulator", "fi-thinclient-multi-region", "fi-thinclient-multi-master", "multi-region-strong", "manual-http-network-fault"}, timeOut = SUITE_SHUTDOWN_TIMEOUT) public void afterSuite() { logger.info("afterSuite Started"); diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/resources/log4j2-test.properties b/sdk/cosmos/azure-cosmos-tests/src/test/resources/log4j2-test.properties index 116ca2900edd..b822e388c0f6 100644 --- a/sdk/cosmos/azure-cosmos-tests/src/test/resources/log4j2-test.properties +++ b/sdk/cosmos/azure-cosmos-tests/src/test/resources/log4j2-test.properties @@ -7,3 +7,7 @@ appender.console.name = STDOUT appender.console.type = Console appender.console.layout.type = PatternLayout appender.console.layout.pattern = %d %5X{pid} [%t] %-5p %c - %m%n + +# HTTP/2 PING handler -- enable DEBUG to see PING send/ACK/timeout/kill-switch activity +logger.http2ping.name = com.azure.cosmos.implementation.http.Http2PingHandler +logger.http2ping.level = debug diff --git a/sdk/cosmos/azure-cosmos-tests/src/test/resources/manual-http-network-fault-testng.xml b/sdk/cosmos/azure-cosmos-tests/src/test/resources/manual-http-network-fault-testng.xml new file mode 100644 index 000000000000..b6127237e422 --- /dev/null +++ b/sdk/cosmos/azure-cosmos-tests/src/test/resources/manual-http-network-fault-testng.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/sdk/cosmos/azure-cosmos/CHANGELOG.md b/sdk/cosmos/azure-cosmos/CHANGELOG.md index 5184003fa2c6..56bc01bfba61 100644 --- a/sdk/cosmos/azure-cosmos/CHANGELOG.md +++ b/sdk/cosmos/azure-cosmos/CHANGELOG.md @@ -14,6 +14,7 @@ * Fixed `UnsupportedOperationException` when using `readManyByPartitionKeys` for empty pages. - See [PR 49311](https://github.com/Azure/azure-sdk-for-java/pull/49311) #### Other Changes +* Added HTTP/2 PING keepalive (default ON) for Gateway service endpoints to detect silently-broken connections. - See [PR 49095](https://github.com/Azure/azure-sdk-for-java/pull/49095) * 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) * Promoted the `ReadConsistencyStrategy` and `Http2ConnectionConfig` related `@Beta` APIs to GA. - See [PR 49345](https://github.com/Azure/azure-sdk-for-java/pull/49345) * 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) diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ClientRetryPolicy.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ClientRetryPolicy.java index 4a5b2f409df7..0fc46f1ab600 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ClientRetryPolicy.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/ClientRetryPolicy.java @@ -139,6 +139,22 @@ public Mono shouldRetry(Exception e) { } else { return this.shouldNotRetryOnEndpointFailureAsync(this.isReadRequest, false, false); } + } else if (clientException != null && + Exceptions.isSubStatusCode(clientException, HttpConstants.SubStatusCodes.GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED)) { + // HTTP/2 PING keepalive closed the channel after consecutive ACK timeouts. + // The remote gateway is NOT known to be unhealthy -- this is typically a local + // transport failure (NAT / LB idle reap of an otherwise-healthy connection). + // Reuse the gateway-timeout path: bounded read retry that may cycle through + // preferred locations (shouldRetryOnGatewayTimeout bumps failoverRetryCount and + // routes via routeToLocation(retryCount, true) -- consistent with the + // GATEWAY_ENDPOINT_READ_TIMEOUT branch below), or noRetry for writes. With a + // single preferred region the retry stays on the same endpoint; with multiple + // preferred regions the next attempt may land on a different one. Critically, + // no markEndpointUnavailableFor* call is made inside shouldRetryOnGatewayTimeout, + // so this avoids the cross-region failover cascade that GATEWAY_ENDPOINT_UNAVAILABLE + // would trigger via refreshLocation + markEndpointUnavailableForRead/Write. + logger.info("HTTP/2 PING-driven connection close. Retrying without endpoint mark-down. ", e); + return shouldRetryOnGatewayTimeout(clientException); } else if (clientException != null && WebExceptionUtility.isReadTimeoutException(clientException) && Exceptions.isSubStatusCode(clientException, HttpConstants.SubStatusCodes.GATEWAY_ENDPOINT_READ_TIMEOUT)) { diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/Configs.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/Configs.java index 18eef0544e18..c1bab22d8cad 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/Configs.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/Configs.java @@ -145,6 +145,36 @@ public class Configs { private static final String CONNECTION_ACQUIRE_TIMEOUT_IN_MS = "COSMOS.CONNECTION_ACQUIRE_TIMEOUT_IN_MS"; private static final String CONNECTION_ACQUIRE_TIMEOUT_IN_MS_VARIABLE = "COSMOS_CONNECTION_ACQUIRE_TIMEOUT_IN_MS"; private static final String REACTOR_NETTY_CONNECTION_POOL_NAME = "reactor-netty-connection-pool"; + + // HTTP/2 PING keepalive — keeps connections alive for sparse workloads by preventing + // intermediate infrastructure (NAT gateways, firewalls, load balancers) from silently + // reaping idle connections. On PING timeout (no ACK within the configured timeout), + // the connection is closed — similar to Rust SDK's hyper-based PING behavior. + // Guarded by an explicit enable flag; default ON. Set COSMOS.HTTP2_PING_HEALTH_ENABLED=false to disable. + private static final boolean DEFAULT_HTTP2_PING_HEALTH_ENABLED = true; + private static final String HTTP2_PING_HEALTH_ENABLED = "COSMOS.HTTP2_PING_HEALTH_ENABLED"; + private static final String HTTP2_PING_HEALTH_ENABLED_VARIABLE = "COSMOS_HTTP2_PING_HEALTH_ENABLED"; + // Aligned with Rust SDK (hyper): interval=1s, timeout=2s. A single missed PING round + // (~3s) marks one failure; the connection is closed after HTTP2_PING_FAILURE_THRESHOLD + // consecutive failures (see ~15s worst-case detection note below). + private static final int DEFAULT_HTTP2_PING_INTERVAL_IN_SECONDS = 1; + private static final String HTTP2_PING_INTERVAL_IN_SECONDS = "COSMOS.HTTP2_PING_INTERVAL_IN_SECONDS"; + private static final String HTTP2_PING_INTERVAL_IN_SECONDS_VARIABLE = "COSMOS_HTTP2_PING_INTERVAL_IN_SECONDS"; + private static final int DEFAULT_HTTP2_PING_TIMEOUT_IN_SECONDS = 2; + private static final String HTTP2_PING_TIMEOUT_IN_SECONDS = "COSMOS.HTTP2_PING_TIMEOUT_IN_SECONDS"; + private static final String HTTP2_PING_TIMEOUT_IN_SECONDS_VARIABLE = "COSMOS_HTTP2_PING_TIMEOUT_IN_SECONDS"; + // Consecutive PING failures (timeout without ACK) before closing the connection. + // Peer HTTP/2 stacks (Hyper / .NET SocketsHttpHandler / Go net/http) typically close + // on the first PING-ACK timeout. Java's threshold of 5 is intentionally more tolerant + // to absorb transient WAN jitter; with interval=1s and timeout=2s, worst-case + // detection = 5*(1+2) = ~15s. + // Note: this is NOT the same dimension as Rust SDK's + // `http2_consecutive_failure_threshold` (which gates per-HTTP-request shard health, + // not per-PING-ACK timeouts). + private static final int DEFAULT_HTTP2_PING_FAILURE_THRESHOLD = 5; + private static final String HTTP2_PING_FAILURE_THRESHOLD = "COSMOS.HTTP2_PING_FAILURE_THRESHOLD"; + private static final String HTTP2_PING_FAILURE_THRESHOLD_VARIABLE = "COSMOS_HTTP2_PING_FAILURE_THRESHOLD"; + private static final int DEFAULT_HTTP_RESPONSE_TIMEOUT_IN_SECONDS = 60; private static final int DEFAULT_QUERY_PLAN_RESPONSE_TIMEOUT_IN_SECONDS = 5; private static final int DEFAULT_ADDRESS_REFRESH_RESPONSE_TIMEOUT_IN_SECONDS = 5; @@ -740,6 +770,71 @@ public static int getDefaultHttpPoolSize() { return DEFAULT_HTTP_DEFAULT_CONNECTION_POOL_SIZE; } + public static boolean isHttp2PingHealthEnabled() { + String configValue = System.getProperty( + HTTP2_PING_HEALTH_ENABLED, + firstNonNull( + emptyToNull(System.getenv().get(HTTP2_PING_HEALTH_ENABLED_VARIABLE)), + String.valueOf(DEFAULT_HTTP2_PING_HEALTH_ENABLED))); + return Boolean.parseBoolean(configValue); + } + + public static int getHttp2PingIntervalInSeconds() { + return parseIntConfigOrDefault( + HTTP2_PING_INTERVAL_IN_SECONDS, + HTTP2_PING_INTERVAL_IN_SECONDS_VARIABLE, + DEFAULT_HTTP2_PING_INTERVAL_IN_SECONDS); + } + + public static int getHttp2PingTimeoutInSeconds() { + return parseIntConfigOrDefault( + HTTP2_PING_TIMEOUT_IN_SECONDS, + HTTP2_PING_TIMEOUT_IN_SECONDS_VARIABLE, + DEFAULT_HTTP2_PING_TIMEOUT_IN_SECONDS); + } + + public static int getHttp2PingFailureThreshold() { + return parseIntConfigOrDefault( + HTTP2_PING_FAILURE_THRESHOLD, + HTTP2_PING_FAILURE_THRESHOLD_VARIABLE, + DEFAULT_HTTP2_PING_FAILURE_THRESHOLD); + } + + /** + * Reads an int system property first, then the env variable, falling back to the supplied default + * on either absence or a non-numeric value. Logs WARN on malformed input so an operator typo in a + * value like {@code COSMOS_HTTP2_PING_INTERVAL_IN_SECONDS=1s} cannot throw {@link NumberFormatException} + * from inside a per-connection reactor-netty {@code doOnConnected} callback and break the channel. + */ + private static int parseIntConfigOrDefault(String systemPropertyKey, String envVariableKey, int defaultValue) { + String valueFromSystemProperty = System.getProperty(systemPropertyKey); + if (valueFromSystemProperty != null && !valueFromSystemProperty.isEmpty()) { + try { + return Integer.parseInt(valueFromSystemProperty); + } catch (NumberFormatException e) { + logger.warn( + "Invalid non-numeric value '{}' for system property {}. Falling back to environment variable or default.", + valueFromSystemProperty, + systemPropertyKey); + } + } + + String valueFromEnvVariable = System.getenv(envVariableKey); + if (valueFromEnvVariable != null && !valueFromEnvVariable.isEmpty()) { + try { + return Integer.parseInt(valueFromEnvVariable); + } catch (NumberFormatException e) { + logger.warn( + "Invalid non-numeric value '{}' for environment variable {}. Falling back to default: {}.", + valueFromEnvVariable, + envVariableKey, + defaultValue); + } + } + + return defaultValue; + } + public static Integer getPendingAcquireMaxCount() { String valueFromSystemProperty = System.getProperty(HTTP_PENDING_ACQUIRE_MAX_COUNT); if (valueFromSystemProperty != null && !valueFromSystemProperty.isEmpty()) { diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/HttpConstants.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/HttpConstants.java index 570bdb010f25..1ce23ae53459 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/HttpConstants.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/HttpConstants.java @@ -458,6 +458,13 @@ public static class SubStatusCodes { // Client generated request rate too large exception public static final int THROUGHPUT_CONTROL_BULK_REQUEST_RATE_TOO_LARGE = 10005; + // Client generated gateway network error: HTTP/2 PING keepalive detected a dead + // connection (consecutive PING ACK timeouts crossed the failure threshold) and + // closed the channel. This is a LOCAL transport failure, NOT a regional outage -- + // ClientRetryPolicy uses this to skip the endpoint mark-down / cross-region + // failover that GATEWAY_ENDPOINT_UNAVAILABLE would trigger. + public static final int GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED = 10006; + public static final int USER_REQUEST_RATE_TOO_LARGE = 3200; //SDK Codes(Client) 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 787b63dfb326..d4f99c183c24 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 @@ -47,6 +47,7 @@ import com.azure.cosmos.implementation.http.HttpClient; import com.azure.cosmos.implementation.http.HttpClientConfig; import com.azure.cosmos.implementation.http.HttpHeaders; +import com.azure.cosmos.implementation.http.Http2PingHandler; import com.azure.cosmos.implementation.http.SharedGatewayHttpClient; import com.azure.cosmos.implementation.interceptor.ITransportClientInterceptor; import com.azure.cosmos.implementation.patch.PatchUtil; @@ -1674,9 +1675,24 @@ private void addUserAgentSuffix(UserAgentContainer userAgentContainer, Set> queryDatabases(String query, QueryFeedOperationState state) { return queryDatabases(new SqlQuerySpec(query), state); 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 a3cd0c1f0dcc..8d9b324d92fb 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 @@ -750,7 +750,15 @@ private Mono toDocumentServiceResponse(Mono + * This is a strict subset of {@link #isNetworkFailure(Exception)} (the typed + * exception extends {@link java.nio.channels.ClosedChannelException}); callers + * that branch on the PING-close case should check this BEFORE general-network- + * failure handling so the more specific subStatusCode is stamped. + */ + public static boolean isHttp2PingTimeoutClose(Exception ex) { + Exception iterator = ex; + + while (iterator != null) { + if (iterator instanceof Http2PingTimeoutChannelClosedException) { + return true; + } + + Throwable t = iterator.getCause(); + iterator = Utils.as(t, Exception.class); + } + + return false; + } } diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingCloseRewrapHandler.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingCloseRewrapHandler.java new file mode 100644 index 000000000000..02f6cd67dbcf --- /dev/null +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingCloseRewrapHandler.java @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.cosmos.implementation.http; + +import io.netty.channel.Channel; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; + +/** + * Per-request HTTP/2 child-stream handler that translates a parent-TCP-channel close + * driven by {@link Http2PingHandler} into a typed {@link Http2PingTimeoutChannelClosedException}. + *

+ * Installed at the head of each H2 child-stream pipeline by + * {@code ReactorNettyClient}'s {@code .doOnRequest(...)} hook. When the parent (TCP) + * channel is closed by {@link Http2PingHandler} after consecutive PING-ACK timeouts + * or consecutive PING-send failures, the H2 multiplex codec propagates + * {@code channelInactive} to every child stream. + * This handler fires first (head-of-pipeline), inspects the parent channel's + * {@link Http2PingHandler#PING_TIMEOUT_CLOSED} attribute, and on match fires + * {@code exceptionCaught} with the typed exception before the default close + * path reaches reactor-netty's {@code HttpClientOperations}. That makes the in-flight + * request's response {@code Mono} fail with the typed exception (which the rest of the + * stack maps to {@code GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED}) instead of a bare + * {@code PrematureCloseException}, which would otherwise trigger region mark-down in + * {@code ClientRetryPolicy}. + *

+ * The handler is stateless and marked {@link ChannelHandler.Sharable}, so a single + * JVM-wide {@link #INSTANCE} is reused across all H2 child streams. + *

+ * For non-H2 channels (parent is {@code null}) this handler is never installed; the + * install site in {@code ReactorNettyClient} gates on {@code ch.parent() != null}. + */ +@ChannelHandler.Sharable +final class Http2PingCloseRewrapHandler extends ChannelInboundHandlerAdapter { + + static final String HANDLER_NAME = "cosmos.http2PingCloseRewrap"; + + static final Http2PingCloseRewrapHandler INSTANCE = new Http2PingCloseRewrapHandler(); + + private Http2PingCloseRewrapHandler() {} + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + Channel parent = ctx.channel().parent(); + if (parent != null && Boolean.TRUE.equals(parent.attr(Http2PingHandler.PING_TIMEOUT_CLOSED).get())) { + // Fire BEFORE delegating to super.channelInactive so reactor-netty's + // HttpClientOperations.exceptionCaught fails the response Mono with the + // typed exception, beating its own onInboundClose(PrematureCloseException). + ctx.fireExceptionCaught(new Http2PingTimeoutChannelClosedException( + "HTTP/2 connection closed by PING keepalive after consecutive PING-ACK timeouts or PING-send failures.", + null)); + } + super.channelInactive(ctx); + } +} diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingHandler.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingHandler.java new file mode 100644 index 000000000000..82782d14c71b --- /dev/null +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingHandler.java @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.cosmos.implementation.http; + +import com.azure.cosmos.Http2ConnectionConfig; +import com.azure.cosmos.implementation.Configs; +import com.azure.cosmos.implementation.ImplementationBridgeHelpers; +import io.netty.channel.Channel; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.http2.DefaultHttp2PingFrame; +import io.netty.handler.codec.http2.Http2PingFrame; +import io.netty.util.AttributeKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Manual HTTP/2 PING keepalive handler installed on the parent (TCP) channel. + *

+ * Sends PING frames when the connection is idle for longer than the configured interval, + * preventing L7 middleboxes (NAT gateways, firewalls, load balancers) from silently + * reaping the connection. Modeled after Rust SDK's hyper-based HTTP/2 PING approach. + *

+ * When a PING ACK is not received within the configured timeout, the handler closes + * the connection. The connection pool will then create a fresh replacement. This is + * aligned with the Rust SDK where hyper kills connections on PING timeout and the + * shard health sweep replaces them. + *

+ * Why manual instead of reactor-netty native {@code pingAckTimeout}? Native PING requires + * built-in {@code maxIdleTime} handling, which is bypassed when a custom + * {@code evictionPredicate} is configured (reactor-netty 1.2.13). + *

+ * Note: the Cosmos DB Proxy (Standard Gateway, ThinClient) uses nghttp2 which auto-ACKs + * PINGs per RFC 9113 §6.7. The SQLx Mux uses a custom frame parser that currently rejects + * PING with PROTOCOL_ERROR — but SQLx does not negotiate H2 via ALPN, so clients never + * speak H2 to it. + */ +public class Http2PingHandler extends ChannelDuplexHandler { + + private static final Logger logger = LoggerFactory.getLogger(Http2PingHandler.class); + + private static final String HANDLER_NAME = "cosmos.http2PingHandler"; + + /** + * Marker attribute set on the parent (TCP) channel immediately before {@link + * ChannelHandlerContext#close()} when this handler closes the channel due to + * consecutive PING ACK timeouts (or PING send failures). Downstream code can + * use this to discriminate a PING-driven close from other channel closures. + *

+ * Consumed by {@link Http2PingCloseRewrapHandler}, a {@code @Sharable} handler + * installed at the head of each H2 child-stream pipeline by {@code + * ReactorNettyClient.doOnRequest(...)}. When the parent channel closes with this + * attribute set, the rewrap handler fires {@code exceptionCaught} with a typed + * {@link Http2PingTimeoutChannelClosedException} so the in-flight request's + * response {@code Mono} fails with the typed exception (instead of a bare + * {@code PrematureCloseException}); upstream {@code ClientRetryPolicy} then + * suppresses region mark-down for the corresponding subStatusCode. + */ + public static final AttributeKey PING_TIMEOUT_CLOSED = + AttributeKey.valueOf("cosmos.http2PingTimeoutClosed"); + + private final long pingIntervalNanos; + private final long pingTimeoutNanos; + private final int failureThreshold; + + // Mutable fields below are accessed only from the channel's EventLoop thread + // (handlerAdded, channelRead, scheduled task, writeAndFlush listener), so no + // synchronization or volatile is needed. + // + // nanoTime of the last connection-level activity (inbound frame or PING send). + // Note: H2 stream frames (HEADERS/DATA -- i.e. actual HTTP responses) are + // dispatched by Http2MultiplexHandler to child channels and never surface on + // the parent pipeline, so this does NOT track request/response traffic; it + // tracks PING ACKs, SETTINGS, GOAWAY, and our own PING sends. + private long lastActivityNanos; + private long pingOutstandingSinceNanos; // nanoTime when PING was sent; 0 = no outstanding PING + private int consecutiveFailures; // incremented on timeout, reset on ACK + private int pingsSent; + private ScheduledFuture pingTask; + + /** + * @param pingIntervalSeconds interval in seconds; when idle longer than this, a PING is sent + * @param pingTimeoutSeconds timeout in seconds per PING attempt + * @param failureThreshold consecutive timeouts before closing the connection + */ + public Http2PingHandler(int pingIntervalSeconds, int pingTimeoutSeconds, int failureThreshold) { + this.pingIntervalNanos = TimeUnit.SECONDS.toNanos(Math.max(1, pingIntervalSeconds)); + this.pingTimeoutNanos = TimeUnit.SECONDS.toNanos(Math.max(1, pingTimeoutSeconds)); + this.failureThreshold = Math.max(1, failureThreshold); + this.lastActivityNanos = System.nanoTime(); + } + + @Override + public void handlerAdded(ChannelHandlerContext ctx) { + // Schedule periodic check -- runs on the channel's event loop (single-threaded, no sync needed) + // Check at interval/2 (min 500ms) to bound worst-case PING send delay to ~1.5× interval. + long checkIntervalMs = Math.max(500, TimeUnit.NANOSECONDS.toMillis(pingIntervalNanos) / 2); + this.pingTask = ctx.executor().scheduleAtFixedRate( + () -> maybeSendPing(ctx), + checkIntervalMs, + checkIntervalMs, + TimeUnit.MILLISECONDS); + + logger.debug("Http2PingHandler installed on channel {}, interval={}s, timeout={}s, checkEvery={}ms", + ctx.channel(), + TimeUnit.NANOSECONDS.toSeconds(pingIntervalNanos), + TimeUnit.NANOSECONDS.toSeconds(pingTimeoutNanos), + checkIntervalMs); + } + + @Override + public void handlerRemoved(ChannelHandlerContext ctx) { + cancelPingTask(); + } + + @Override + public void channelInactive(ChannelHandlerContext ctx) throws Exception { + cancelPingTask(); + super.channelInactive(ctx); + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + lastActivityNanos = System.nanoTime(); + if (msg instanceof Http2PingFrame) { + Http2PingFrame ping = (Http2PingFrame) msg; + // RFC 9113 §6.7: PING ACK echoes the 8-byte payload we sent. + // Two guards prevent a late ACK from masking ongoing degradation: + // 1) pingOutstandingSinceNanos != 0 -- after timeout fires we clear + // this to 0; a late ACK arriving before the next PING is sent + // still has ping.content() == pingsSent (pingsSent isn't bumped + // until the next send), so the payload alone is insufficient. + // 2) ping.content() == pingsSent -- guards the window AFTER the next + // PING is sent (pingsSent has advanced), so a late ACK for the + // previous PING no longer matches. + if (ping.ack() && pingOutstandingSinceNanos != 0 && ping.content() == pingsSent) { + pingOutstandingSinceNanos = 0; + consecutiveFailures = 0; + } + } + super.channelRead(ctx, msg); + } + + private void maybeSendPing(ChannelHandlerContext ctx) { + // Channel-dead is a hard stop -- cancel the timer. + if (!ctx.channel().isActive()) { + cancelPingTask(); + return; + } + // Soft gate re-checked per tick: the global kill-switch + // Configs.isHttp2PingHealthEnabled() (system property). Flipping it false + // makes this tick a no-op but KEEPS the timer alive, so toggling it back + // to true automatically resumes PINGing on the same connection (no need + // to wait for connection recycling). Clear any in-flight PING state so a + // long dormant window followed by re-enable cannot charge a spurious + // timeout from a pre-disable outstanding PING. + if (!Configs.isHttp2PingHealthEnabled()) { + pingOutstandingSinceNanos = 0; + consecutiveFailures = 0; + return; + } + + // If a previous PING is still outstanding, check whether it has timed out + if (pingOutstandingSinceNanos != 0) { + long waitingNanos = System.nanoTime() - pingOutstandingSinceNanos; + if (waitingNanos >= pingTimeoutNanos) { + consecutiveFailures++; + pingOutstandingSinceNanos = 0; // unblock next PING attempt + + if (consecutiveFailures >= failureThreshold) { + // Threshold reached -- connection is broken. + logger.info("PING ACK not received for {} consecutive attempts on channel {} -- closing connection", + consecutiveFailures, ctx.channel()); + cancelPingTask(); + // Mark BEFORE close so Http2PingCloseRewrapHandler.channelInactive on each + // H2 child stream observes the attribute when the multiplex codec fires + // channelInactive. Same event loop, no cross-thread visibility concerns. + ctx.channel().attr(PING_TIMEOUT_CLOSED).set(Boolean.TRUE); + ctx.close(); + } else { + logger.debug("PING ACK timeout on channel {} (attempt {}/{}) -- will retry", + ctx.channel(), consecutiveFailures, failureThreshold); + } + } + // Don't send another PING while one is outstanding + return; + } + + long idleNanos = System.nanoTime() - lastActivityNanos; + if (idleNanos >= pingIntervalNanos) { + int count = ++pingsSent; + pingOutstandingSinceNanos = System.nanoTime(); + ctx.writeAndFlush(new DefaultHttp2PingFrame(count)) + .addListener(f -> { + if (f.isSuccess()) { + logger.debug("PING #{} sent on channel {}", count, ctx.channel()); + } else { + // Listener runs on the same event loop as the scheduled task, + // so mutating these fields is thread-safe. + // A failed write is a failed health probe -- count it toward the + // close threshold. Otherwise a channel stuck in a state where + // writes always fail but isActive() remains true (e.g. H2 codec + // rejecting frames, stalled flow-control, queued ClosedChannelException + // not yet propagated) would loop here forever without ever closing. + pingOutstandingSinceNanos = 0; + consecutiveFailures++; + if (consecutiveFailures >= failureThreshold) { + logger.info("PING send failed for {} consecutive attempts on channel {} -- closing connection", + consecutiveFailures, ctx.channel()); + cancelPingTask(); + // Same mark-before-close rationale as the ACK-timeout close site above. + ctx.channel().attr(PING_TIMEOUT_CLOSED).set(Boolean.TRUE); + ctx.close(); + } else { + logger.debug("PING #{} send failed on channel {} (attempt {}/{}): {}", + count, ctx.channel(), consecutiveFailures, failureThreshold, + f.cause() != null ? f.cause().getMessage() : "unknown"); + } + } + }); + // Reset activity timestamp so we don't send another PING immediately + lastActivityNanos = System.nanoTime(); + } + } + + private void cancelPingTask() { + if (pingTask != null) { + pingTask.cancel(false); + pingTask = null; + } + } + + /** + * Returns {@code true} when the manual HTTP/2 PING keepalive should be active for + * a client built with the given {@link Http2ConnectionConfig}. This is the single + * source of truth shared by the transport install site (see + * {@link ReactorNettyClient}) and the user-agent flag emission (see + * {@code RxDocumentClientImpl}) so the two cannot drift. + *

+ * All three gates must pass: + *

    + *
  • The global kill-switch {@link Configs#isHttp2PingHealthEnabled()} is true.
  • + *
  • {@link Configs#getHttp2PingIntervalInSeconds()} is > 0 (a non-positive + * interval disables the handler at install time).
  • + *
  • HTTP/2 is effectively enabled for this client + * (either via {@link Configs#isHttp2Enabled()} or the per-client + * {@link Http2ConnectionConfig} override).
  • + *
+ */ + public static boolean isPingHealthEffectivelyEnabled(Http2ConnectionConfig http2Cfg) { + if (!Configs.isHttp2PingHealthEnabled()) { + return false; + } + if (Configs.getHttp2PingIntervalInSeconds() <= 0) { + return false; + } + return ImplementationBridgeHelpers.Http2ConnectionConfigHelper + .getHttp2ConnectionConfigAccessor() + .isEffectivelyEnabled(http2Cfg); + } + + /** + * Installs the PING handler on the parent H2 channel if not already installed. + * For reactor-netty 1.2.x, {@code doOnConnected} fires on the parent TCP channel + * (State.CONFIGURED) and not on child stream channels (STREAM_CONFIGURED), so the + * input {@code channel} here is normally the parent already. The parent resolution + * and idempotency guard below are defensive in case that contract ever changes. + * + * @param channel the channel from doOnConnected (normally the parent, but child-safe) + * @param pingIntervalSeconds PING interval in seconds + * @param pingTimeoutSeconds PING ACK timeout in seconds + * @param failureThreshold consecutive timeouts before closing + */ + public static void installIfAbsent(Channel channel, int pingIntervalSeconds, int pingTimeoutSeconds, + int failureThreshold) { + Channel parent = channel.parent() != null ? channel.parent() : channel; + if (parent.pipeline().get(HANDLER_NAME) == null) { + try { + parent.pipeline().addLast(HANDLER_NAME, + new Http2PingHandler(pingIntervalSeconds, pingTimeoutSeconds, failureThreshold)); + } catch (IllegalArgumentException ignored) { + // Duplicate -- race between concurrent streams, benign + } + } + } +} diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingTimeoutChannelClosedException.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingTimeoutChannelClosedException.java new file mode 100644 index 000000000000..fc28a056f3dc --- /dev/null +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/Http2PingTimeoutChannelClosedException.java @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.cosmos.implementation.http; + +import java.nio.channels.ClosedChannelException; + +/** + * Marker exception raised when {@link Http2PingHandler} closes an HTTP/2 parent + * channel after consecutive PING ACK timeouts (or PING send failures) crossed + * the configured threshold. + *

+ * This is a local transport failure -- typically caused by NAT / load + * balancer idle reaping of an otherwise-healthy connection. The remote + * service (e.g. Cosmos DB Standard Gateway or ThinClient proxy) is NOT + * known to be unhealthy. Code paths that classify network failures (e.g. + * {@code com.azure.cosmos.implementation.ClientRetryPolicy}) should use this + * marker to suppress region mark-down / cross-region failover and instead + * retry against the same regional endpoint with a fresh connection. + *

+ * Extends {@link ClosedChannelException} so existing classifiers (notably + * {@code WebExceptionUtility.isNetworkFailure}) continue to recognize it as a + * network failure; discrimination from a generic closed channel is via {@code + * instanceof} (see {@code WebExceptionUtility.isHttp2PingTimeoutClose}). + *

+ * Note: {@link ClosedChannelException} only exposes a no-arg constructor, so + * the message is carried in a private field and surfaced via {@link + * #getMessage()}. + */ +public final class Http2PingTimeoutChannelClosedException extends ClosedChannelException { + + private static final long serialVersionUID = 1L; + + private final String message; + + public Http2PingTimeoutChannelClosedException(String message, Throwable cause) { + super(); + this.message = message; + if (cause != null) { + initCause(cause); + } + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/ReactorNettyClient.java b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/ReactorNettyClient.java index 2e362eadc084..e95989c5aa3c 100644 --- a/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/ReactorNettyClient.java +++ b/sdk/cosmos/azure-cosmos/src/main/java/com/azure/cosmos/implementation/http/ReactorNettyClient.java @@ -11,6 +11,7 @@ import io.netty.channel.ChannelOption; import io.netty.channel.ChannelPipeline; import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http2.Http2MultiplexHandler; import io.netty.handler.logging.LogLevel; import io.netty.resolver.DefaultAddressResolverGroup; import io.netty.util.ReferenceCountUtil; @@ -43,7 +44,7 @@ * HttpClient that is implemented using reactor-netty. */ public class ReactorNettyClient implements HttpClient { - private static ImplementationBridgeHelpers.Http2ConnectionConfigHelper.Http2ConnectionConfigAccessor httpCfgAccessor() { + private static ImplementationBridgeHelpers.Http2ConnectionConfigHelper.Http2ConnectionConfigAccessor http2CfgAccessor() { return ImplementationBridgeHelpers.Http2ConnectionConfigHelper.getHttp2ConnectionConfigAccessor(); } @@ -141,7 +142,38 @@ private void configureChannelPipelineHandlers() { .validateHeaders(true)); Http2ConnectionConfig http2Cfg = httpClientConfig.getHttp2ConnectionConfig(); - if (httpCfgAccessor().isEffectivelyEnabled(http2Cfg)) { + + boolean isH2Enabled = http2CfgAccessor().isEffectivelyEnabled(http2Cfg); + + if (isH2Enabled) { + this.httpClient = this.httpClient.doOnConnected(connection -> { + // Manual HTTP/2 PING keepalive -- sends PING frames when the connection is idle + // to prevent L7 middleboxes (NAT, firewalls, LBs) from reaping the connection. + // For H2, doOnConnected fires on the parent TCP channel when the connection + // is first established (State.CONFIGURED). Child stream channels emit + // STREAM_CONFIGURED, which does not trigger this callback. The parent- + // resolution and installIfAbsent guard below are defensive -- they correctly + // handle both parent and child channels if the reactor-netty contract + // ever changes. + // + // Gating is consolidated in Http2PingHandler.isPingHealthEffectivelyEnabled so + // the transport install site and the user-agent feature flag cannot drift. + // The handler also re-checks the kill-switch per tick so toggling it at + // runtime immediately stops/resumes PINGing on already-installed connections. + if (Http2PingHandler.isPingHealthEffectivelyEnabled(http2Cfg)) { + // Resolve to the parent (TCP) channel -- defensive in case reactor-netty + // ever invokes this callback for a child stream channel. + Channel ch = connection.channel(); + Channel parent = ch.parent() != null ? ch.parent() : ch; + if (parent.pipeline().get(Http2MultiplexHandler.class) != null) { + Http2PingHandler.installIfAbsent(parent, + Configs.getHttp2PingIntervalInSeconds(), + Configs.getHttp2PingTimeoutInSeconds(), + Configs.getHttp2PingFailureThreshold()); + } + } + }); + this.httpClient = this.httpClient .secure(sslContextSpec -> sslContextSpec.sslContext( @@ -153,18 +185,25 @@ private void configureChannelPipelineHandlers() { .http2Settings(settings -> settings .initialWindowSize(1024 * 1024) // 1MB initial window size .maxFrameSize(64 * 1024) // 64KB max frame size - .maxConcurrentStreams(httpCfgAccessor().getEffectiveMaxConcurrentStreams(http2Cfg)) // Increased from default 30 + .maxConcurrentStreams(http2CfgAccessor().getEffectiveMaxConcurrentStreams(http2Cfg)) // Increased from default 30 ) .doOnConnected((connection -> { // The response header clean up pipeline is being added due to an error getting when calling gateway: // java.lang.IllegalArgumentException: a header value contains prohibited character 0x20 at index 0 for 'x-ms-serviceversion', there is whitespace in the front of the value. // validateHeaders(false) does not work for http2 ChannelPipeline channelPipeline = connection.channel().pipeline(); - if (channelPipeline.get("reactor.left.httpCodec") != null) { - channelPipeline.addAfter( - "reactor.left.httpCodec", - "customHeaderCleaner", - new Http2ResponseHeaderCleanerHandler()); + if (channelPipeline.get("reactor.left.httpCodec") != null + && channelPipeline.get("customHeaderCleaner") == null) { + try { + channelPipeline.addAfter( + "reactor.left.httpCodec", + "customHeaderCleaner", + new Http2ResponseHeaderCleanerHandler()); + } catch (IllegalArgumentException ignored) { + // TOCTOU race: between the get()==null check above and addAfter(), + // a concurrent doOnConnected may have installed the handler. + // Duplicate handler name is the only possible cause. + } } // Install exception handler at the tail of the HTTP/2 parent (TCP) @@ -184,7 +223,35 @@ private void configureChannelPipelineHandlers() { // Duplicate handler name is the only possible cause. } } - })); + })) + .doOnRequest((req, conn) -> { + // Install a @Sharable head-of-pipeline rewrap handler on each H2 + // child-stream pipeline. When Http2PingHandler closes the parent + // (TCP) channel after consecutive PING-ACK timeouts or PING-send + // failures, the H2 multiplex codec propagates channelInactive to + // every child stream; the rewrap handler intercepts that and fires + // exceptionCaught with a typed + // Http2PingTimeoutChannelClosedException so reactor-netty's + // HttpClientOperations fails the response Mono with the typed + // exception (instead of bare PrematureCloseException). The rest of the + // stack maps that to GATEWAY_HTTP2_PING_TIMEOUT_CHANNEL_CLOSED so + // ClientRetryPolicy can suppress region mark-down. + // + // Gate on ch.parent() != null so this only runs on H2 child streams + // (H1.1 connections have null parent and never need the rewrap). + Channel ch = conn.channel(); + if (ch.parent() != null && ch.pipeline().get(Http2PingCloseRewrapHandler.HANDLER_NAME) == null) { + try { + ch.pipeline().addFirst( + Http2PingCloseRewrapHandler.HANDLER_NAME, + Http2PingCloseRewrapHandler.INSTANCE); + } catch (IllegalArgumentException ignored) { + // TOCTOU race: between the get()==null check above and addFirst(), + // a concurrent doOnRequest may have installed the handler. + // Duplicate handler name is the only possible cause. + } + } + }); } } @@ -253,7 +320,8 @@ public Mono send(final HttpRequest request, Duration responseTimeo * @param restRequest the Rest request contains the body to be sent * @return a delegate upon invocation sets the request body in reactor-netty outbound object */ - private static BiFunction> bodySendDelegate(final HttpRequest restRequest) { + private static BiFunction> bodySendDelegate( + final HttpRequest restRequest) { return (reactorNettyRequest, reactorNettyOutbound) -> { for (HttpHeader header : restRequest.headers()) { reactorNettyRequest.header(header.name(), header.value()); diff --git a/sdk/cosmos/live-http2-network-fault-platform-matrix.json b/sdk/cosmos/live-http2-network-fault-platform-matrix.json new file mode 100644 index 000000000000..8268fdedb324 --- /dev/null +++ b/sdk/cosmos/live-http2-network-fault-platform-matrix.json @@ -0,0 +1,41 @@ +{ + "displayNames": { + "-Pmanual-http-network-fault": "HttpNetworkFaultThinClient", + "-Pmanual-http-network-fault-compute": "HttpNetworkFaultCompute", + "Session": "", + "ubuntu": "", + "@{ enableMultipleWriteLocations = $true; defaultConsistencyLevel = 'Session'; enableMultipleRegions = $true }": "http2-network-fault" + }, + "include": [ + { + "DESIRED_CONSISTENCIES": "[\"Session\"]", + "ACCOUNT_CONSISTENCY": "Session", + "ArmConfig": { + "MultiMaster_MultiRegion": { + "ArmTemplateParameters": "@{ enableMultipleWriteLocations = $true; defaultConsistencyLevel = 'Session'; enableMultipleRegions = $true }", + "PREFERRED_LOCATIONS": "[\"East US 2\"]" + } + }, + "PROTOCOLS": "[\"Tcp\"]", + "ProfileFlag": [ "-Pmanual-http-network-fault" ], + "Agent": { + "ubuntu": { "OSVmImage": "env:LINUXVMIMAGE", "Pool": "env:LINUXPOOL" } + } + }, + { + "DESIRED_CONSISTENCIES": "[\"Session\"]", + "ACCOUNT_CONSISTENCY": "Session", + "ArmConfig": { + "MultiMaster_MultiRegion": { + "ArmTemplateParameters": "@{ enableMultipleWriteLocations = $true; defaultConsistencyLevel = 'Session'; enableMultipleRegions = $true }", + "PREFERRED_LOCATIONS": "[\"East US 2\"]" + } + }, + "PROTOCOLS": "[\"Tcp\"]", + "ProfileFlag": [ "-Pmanual-http-network-fault-compute" ], + "Agent": { + "ubuntu": { "OSVmImage": "env:LINUXVMIMAGE", "Pool": "env:LINUXPOOL" } + } + } + ] +} diff --git a/sdk/cosmos/tests.yml b/sdk/cosmos/tests.yml index 3e0cab41689d..0eecf42166ea 100644 --- a/sdk/cosmos/tests.yml +++ b/sdk/cosmos/tests.yml @@ -67,6 +67,44 @@ extends: - name: AdditionalArgs value: '-DCOSMOS.CLIENT_LEAK_DETECTION_ENABLED=true -DCOSMOS.HTTP2_ENABLED=true' + - template: /eng/pipelines/templates/stages/archetype-sdk-tests-isolated.yml + parameters: + TestName: 'Cosmos_Live_Test_Http2NetworkFault' + PreSteps: + - script: | + sudo apt-get update -qq && sudo apt-get install -y -qq iptables + echo \"iptables $(sudo iptables --version 2>/dev/null || echo 'not found')\" + displayName: 'Install iptables for network fault injection' + CloudConfig: + Public: + ServiceConnection: azure-sdk-tests-cosmos + MatrixConfigs: + - Name: Cosmos_live_test_http2_network_fault + Path: sdk/cosmos/live-http2-network-fault-platform-matrix.json + Selection: all + GenerateVMJobs: true + MatrixReplace: + - .*Version=1.2(1|5)/1.17 + ServiceDirectory: cosmos + Artifacts: + - name: azure-cosmos + groupId: com.azure + safeName: azurecosmos + AdditionalModules: + - name: azure-cosmos-tests + groupId: com.azure + TimeoutInMinutes: 30 + MaxParallel: 1 + TestGoals: 'verify' + TestOptions: '$(ProfileFlag) $(AdditionalArgs) -DskipCompile=true -DskipTestCompile=true -DcreateSourcesJar=false' + TestResultsFiles: '**/junitreports/TEST-*.xml' + AdditionalVariables: + - name: AdditionalArgs + # COSMOS.THINCLIENT_ENABLED / HTTP2_* are driven by the per-profile + # in azure-cosmos-tests/pom.xml so the Compute + # matrix leg can route to the regional gateway on :443 instead of :10250. + value: '-DCOSMOS.CLIENT_LEAK_DETECTION_ENABLED=true -DACCOUNT_HOST=$(thinclient-test-endpoint) -DACCOUNT_KEY=$(thinclient-test-key)' + - template: /eng/pipelines/templates/stages/archetype-sdk-tests-isolated.yml parameters: TestName: 'Cosmos_Live_Test_ThinClient'