This is an automated email from the ASF dual-hosted git repository.

capistrant pushed a commit to branch 39.0.0
in repository https://gitbox.apache.org/repos/asf/druid.git


The following commit(s) were added to refs/heads/39.0.0 by this push:
     new 21e8f6409ea fix!: send query response trailers only to clients that 
send TE: trailers (#20717) (#20751)
21e8f6409ea is described below

commit 21e8f6409ea8328c839015d7bfd69ba8693c05f6
Author: Lucas Capistrant <[email protected]>
AuthorDate: Tue Oct 27 20:32:22 2026 -0500

    fix!: send query response trailers only to clients that send TE: trailers 
(#20717) (#20751)
    
    JDK 17.0.21, 21.0.13 and 25.0.5 (JDK-8383439) make java.net.http.HttpClient
    reject chunked responses with a trailer section (ProtocolException: CR
    expected). Brokers and Historicals always sent X-Druid-Response-Complete as 
a
    trailer, so streamed query responses failed for those clients. Only declare
    and send trailers when the request has TE: trailers (RFC 9110 10.1.4).
    
    Co-authored-by: Frank Chen <[email protected]>
---
 .../embedded/server/QueryResponseTrailerTest.java  | 188 +++++++++++++++++++++
 .../org/apache/druid/server/QueryResultPusher.java |  39 ++++-
 .../org/apache/druid/server/QueryResourceTest.java |  14 ++
 .../apache/druid/server/QueryResultPusherTest.java |  23 +++
 .../druid/server/mocks/MockHttpServletRequest.java |   4 +-
 5 files changed, 261 insertions(+), 7 deletions(-)

diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/QueryResponseTrailerTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/QueryResponseTrailerTest.java
new file mode 100644
index 00000000000..afaf40f4344
--- /dev/null
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/server/QueryResponseTrailerTest.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.testing.embedded.server;
+
+import org.apache.druid.server.QueryResource;
+import org.apache.druid.testing.embedded.EmbeddedBroker;
+import org.apache.druid.testing.embedded.EmbeddedCoordinator;
+import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
+import org.apache.druid.testing.embedded.EmbeddedDruidServer;
+import org.apache.druid.testing.embedded.EmbeddedHistorical;
+import org.apache.druid.testing.embedded.EmbeddedOverlord;
+import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.InputStream;
+import java.net.Socket;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Verifies that streamed query responses carry the {@link 
QueryResource#RESPONSE_COMPLETE_TRAILER_HEADER} trailer only
+ * when the client sends {@code TE: trailers}, so that clients which reject 
trailers, such as
+ * {@link java.net.http.HttpClient} on recent JDK updates, can read query 
results.
+ */
+public class QueryResponseTrailerTest extends EmbeddedClusterTestBase
+{
+  private static final String SQL_QUERY = "{\"query\": \"SELECT 1\"}";
+  private static final String NATIVE_QUERY = """
+      {
+        "queryType": "timeseries",
+        "dataSource": "missing_datasource",
+        "granularity": "all",
+        "intervals": ["2000/3000"],
+        "aggregations": [{"type": "count", "name": "rows"}]
+      }
+      """;
+
+  private final EmbeddedCoordinator coordinator = new EmbeddedCoordinator();
+  private final EmbeddedOverlord overlord = new EmbeddedOverlord();
+  private final EmbeddedBroker broker = new EmbeddedBroker();
+  private final EmbeddedHistorical historical = new EmbeddedHistorical();
+  private final HttpClient client = HttpClient.newHttpClient();
+
+  @Override
+  protected EmbeddedDruidCluster createCluster()
+  {
+    return EmbeddedDruidCluster.withEmbeddedDerbyAndZookeeper()
+                               .addServer(coordinator)
+                               .addServer(overlord)
+                               .addServer(broker)
+                               .addServer(historical);
+  }
+
+  @AfterAll
+  public void closeClient()
+  {
+    client.close();
+  }
+
+  @Test
+  @Timeout(30)
+  public void testTrailersOnlyWhenClientSendsTeTrailers() throws Exception
+  {
+    for (final String path : new String[]{"/druid/v2/sql", "/druid/v2"}) {
+      final String body = path.endsWith("sql") ? SQL_QUERY : NATIVE_QUERY;
+
+      final String withoutTe = sendRaw(broker, path, body, false);
+      Assertions.assertTrue(withoutTe.startsWith("HTTP/1.1 200"), withoutTe);
+      
Assertions.assertFalse(withoutTe.contains(QueryResource.RESPONSE_COMPLETE_TRAILER_HEADER),
 withoutTe);
+
+      final String withTe = sendRaw(broker, path, body, true);
+      Assertions.assertTrue(withTe.startsWith("HTTP/1.1 200"), withTe);
+      Assertions.assertTrue(
+          withTe.endsWith("\r\n0\r\n" + 
QueryResource.RESPONSE_COMPLETE_TRAILER_HEADER + ": true\r\n\r\n"),
+          withTe
+      );
+    }
+  }
+
+  @Test
+  @Timeout(30)
+  public void testJavaHttpClientReadsStreamedResults() throws Exception
+  {
+    assertJavaHttpClientReadsResults(broker, "/druid/v2/sql", SQL_QUERY);
+    assertJavaHttpClientReadsResults(broker, "/druid/v2", NATIVE_QUERY);
+    assertJavaHttpClientReadsResults(historical, "/druid/v2", NATIVE_QUERY);
+  }
+
+  private void assertJavaHttpClientReadsResults(
+      final EmbeddedDruidServer<?> server,
+      final String path,
+      final String body
+  ) throws Exception
+  {
+    final HttpRequest request = 
HttpRequest.newBuilder(URI.create(getServerUrl(server) + path))
+                                           .header("Content-Type", 
"application/json")
+                                           .timeout(Duration.ofSeconds(10))
+                                           
.POST(HttpRequest.BodyPublishers.ofString(body))
+                                           .build();
+    final HttpResponse<InputStream> response = client.send(request, 
HttpResponse.BodyHandlers.ofInputStream());
+    Assertions.assertEquals(200, response.statusCode());
+    try (InputStream in = response.body()) {
+      Assertions.assertTrue(new String(in.readAllBytes(), 
StandardCharsets.UTF_8).startsWith("["));
+    }
+  }
+
+  /**
+   * Sends a request over a raw keep-alive socket and returns the response 
bytes as they appear on the wire, including
+   * chunk framing and trailers, which {@link HttpClient} does not expose.
+   */
+  private String sendRaw(
+      final EmbeddedDruidServer<?> server,
+      final String path,
+      final String body,
+      final boolean teTrailers
+  ) throws Exception
+  {
+    final URI uri = URI.create(getServerUrl(server));
+    final byte[] bodyBytes = body.getBytes(StandardCharsets.UTF_8);
+    final String request = "POST " + path + " HTTP/1.1\r\n"
+                           + "Host: " + uri.getHost() + "\r\n"
+                           + "Content-Type: application/json\r\n"
+                           + "Content-Length: " + bodyBytes.length + "\r\n"
+                           + (teTrailers ? "TE: trailers\r\nConnection: 
TE\r\n" : "")
+                           + "\r\n";
+    try (Socket socket = new Socket(uri.getHost(), uri.getPort())) {
+      socket.setSoTimeout(10_000);
+      
socket.getOutputStream().write(request.getBytes(StandardCharsets.US_ASCII));
+      socket.getOutputStream().write(bodyBytes);
+      socket.getOutputStream().flush();
+
+      final InputStream in = socket.getInputStream();
+      final StringBuilder response = new StringBuilder();
+      while (!isComplete(response)) {
+        final int b = in.read();
+        if (b < 0) {
+          break;
+        }
+        response.append((char) b);
+      }
+      return response.toString();
+    }
+  }
+
+  private static boolean isComplete(final CharSequence response)
+  {
+    final String text = response.toString();
+    final int headersEnd = text.indexOf("\r\n\r\n");
+    if (headersEnd < 0) {
+      return false;
+    }
+    final String headers = text.substring(0, 
headersEnd).toLowerCase(Locale.ROOT);
+    final Matcher contentLength = Pattern.compile("\r\ncontent-length: 
*(\\d+)").matcher(headers);
+    if (contentLength.find()) {
+      return text.length() >= headersEnd + 4 + 
Integer.parseInt(contentLength.group(1));
+    }
+    // Chunked: complete once the last chunk and the (possibly empty) trailer 
section have been read.
+    final int lastChunk = text.indexOf("\r\n0\r\n", headersEnd);
+    return lastChunk >= 0 && text.endsWith("\r\n\r\n") && text.length() >= 
lastChunk + 7;
+  }
+}
diff --git 
a/server/src/main/java/org/apache/druid/server/QueryResultPusher.java 
b/server/src/main/java/org/apache/druid/server/QueryResultPusher.java
index 6f0b3ee272a..b788f2f47ca 100644
--- a/server/src/main/java/org/apache/druid/server/QueryResultPusher.java
+++ b/server/src/main/java/org/apache/druid/server/QueryResultPusher.java
@@ -51,6 +51,7 @@ import javax.ws.rs.core.Response;
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.OutputStream;
+import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -129,8 +130,10 @@ public abstract class QueryResultPusher
 
       final Response.ResponseBuilder startResponse = resultsWriter.start();
       if (startResponse != null) {
-        startResponse.header(QueryResource.QUERY_ID_RESPONSE_HEADER, queryId)
-                     .header(HttpHeader.TRAILER.toString(), 
RESULT_TRAILER_HEADERS);
+        startResponse.header(QueryResource.QUERY_ID_RESPONSE_HEADER, queryId);
+        if (clientAcceptsTrailers(request)) {
+          startResponse.header(HttpHeader.TRAILER.toString(), 
RESULT_TRAILER_HEADERS);
+        }
 
         for (Map.Entry<String, String> entry : extraHeaders.entrySet()) {
           startResponse.header(entry.getKey(), entry.getValue());
@@ -154,8 +157,10 @@ public abstract class QueryResultPusher
         response.setHeader(entry.getKey(), entry.getValue());
       }
 
-      response.setHeader(HttpHeader.TRAILER.toString(), 
RESULT_TRAILER_HEADERS);
-      response.setTrailerFields(() -> trailerFields);
+      if (clientAcceptsTrailers(request)) {
+        response.setHeader(HttpHeader.TRAILER.toString(), 
RESULT_TRAILER_HEADERS);
+        response.setTrailerFields(() -> trailerFields);
+      }
 
       // Start with complete status
       trailerFields.put(QueryResource.RESPONSE_COMPLETE_TRAILER_HEADER, 
"true");
@@ -321,6 +326,30 @@ public abstract class QueryResultPusher
     }
   }
 
+  /**
+   * Whether the client sent {@code TE: trailers}, which is how a client 
signals that it accepts trailer fields
+   * (RFC 9110 section 10.1.4). Trailers are only sent to such clients, 
because some clients reject a chunked response
+   * that carries trailers: {@link java.net.http.HttpClient} fails with {@code 
java.net.ProtocolException: CR expected}
+   * since JDK 17.0.21, 21.0.13 and 25.0.5 (JDK-8383439).
+   */
+  static boolean clientAcceptsTrailers(final HttpServletRequest request)
+  {
+    final Enumeration<String> teHeaders = 
request.getHeaders(HttpHeader.TE.asString());
+    if (teHeaders == null) {
+      return false;
+    }
+    while (teHeaders.hasMoreElements()) {
+      for (final String coding : teHeaders.nextElement().split(",")) {
+        final int paramsStart = coding.indexOf(';');
+        final String codingName = paramsStart < 0 ? coding : 
coding.substring(0, paramsStart);
+        if ("trailers".equalsIgnoreCase(codingName.trim())) {
+          return true;
+        }
+      }
+    }
+    return false;
+  }
+
   /**
    * Generates a response for a {@link DruidException} that occurs prior to 
any query results being sent out.
    */
@@ -448,8 +477,6 @@ public abstract class QueryResultPusher
 
         response.setContentType(contentType.toString());
 
-        response.setTrailerFields(() -> trailerFields);
-
         try {
           out = new CountingOutputStream(response.getOutputStream());
           writer = resultsWriter.makeWriter(out);
diff --git 
a/server/src/test/java/org/apache/druid/server/QueryResourceTest.java 
b/server/src/test/java/org/apache/druid/server/QueryResourceTest.java
index 6a62b4d77c1..b37593b14ff 100644
--- a/server/src/test/java/org/apache/druid/server/QueryResourceTest.java
+++ b/server/src/test/java/org/apache/druid/server/QueryResourceTest.java
@@ -343,6 +343,7 @@ public class QueryResourceTest
   public void testGoodQuery() throws IOException
   {
     expectPermissiveHappyPathAuth();
+    testServletRequest.headers.put(HttpHeader.TE.asString(), "trailers");
 
     HttpServletResponse servletResponse = 
expectAsyncRequestFlow(SIMPLE_TIMESERIES_QUERY);
     Assertions.assertEquals(200, servletResponse.getStatus());
@@ -355,6 +356,17 @@ public class QueryResourceTest
     
Assertions.assertEquals(fields.get(QueryResource.RESPONSE_COMPLETE_TRAILER_HEADER),
 "true");
   }
 
+  @Test
+  public void testGoodQueryWithoutTeTrailersOmitsTrailers() throws IOException
+  {
+    expectPermissiveHappyPathAuth();
+
+    final MockHttpServletResponse servletResponse = 
expectAsyncRequestFlow(SIMPLE_TIMESERIES_QUERY);
+    Assertions.assertEquals(200, servletResponse.getStatus());
+    
Assertions.assertFalse(servletResponse.containsHeader(HttpHeader.TRAILER.toString()));
+    Assertions.assertNull(servletResponse.getTrailerFields());
+  }
+
   @Test
   public void testGoodQueryWithQueryConfigOverrideDefault() throws IOException
   {
@@ -547,6 +559,7 @@ public class QueryResourceTest
     );
 
     expectPermissiveHappyPathAuth();
+    testServletRequest.headers.put(HttpHeader.TE.asString(), "trailers");
 
     HttpServletResponse response = expectAsyncRequestFlow(testServletRequest, 
SIMPLE_TIMESERIES_QUERY.getBytes(StandardCharsets.UTF_8), queryResource);
 
@@ -714,6 +727,7 @@ public class QueryResourceTest
     );
 
     expectPermissiveHappyPathAuth();
+    testServletRequest.headers.put(HttpHeader.TE.asString(), "trailers");
 
     HttpServletResponse response = expectAsyncRequestFlow(testServletRequest, 
SIMPLE_TIMESERIES_QUERY.getBytes(StandardCharsets.UTF_8), queryResource);
 
diff --git 
a/server/src/test/java/org/apache/druid/server/QueryResultPusherTest.java 
b/server/src/test/java/org/apache/druid/server/QueryResultPusherTest.java
index ce3e07d9bfc..ab6cb87154a 100644
--- a/server/src/test/java/org/apache/druid/server/QueryResultPusherTest.java
+++ b/server/src/test/java/org/apache/druid/server/QueryResultPusherTest.java
@@ -26,6 +26,7 @@ import 
org.apache.druid.server.QueryResource.QueryMetricCounter;
 import org.apache.druid.server.QueryResultPusher.ResultsWriter;
 import org.apache.druid.server.QueryResultPusher.Writer;
 import org.apache.druid.server.mocks.MockHttpServletRequest;
+import org.eclipse.jetty.http.HttpHeader;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -50,6 +51,28 @@ public class QueryResultPusherTest
       true,
       false);
 
+  @Test
+  public void testClientAcceptsTrailers()
+  {
+    
Assertions.assertFalse(QueryResultPusher.clientAcceptsTrailers(requestWithTe(null)));
+    
Assertions.assertFalse(QueryResultPusher.clientAcceptsTrailers(requestWithTe("")));
+    
Assertions.assertFalse(QueryResultPusher.clientAcceptsTrailers(requestWithTe("gzip,
 deflate;q=0.5")));
+    
Assertions.assertFalse(QueryResultPusher.clientAcceptsTrailers(requestWithTe("trailersx")));
+    
Assertions.assertTrue(QueryResultPusher.clientAcceptsTrailers(requestWithTe("trailers")));
+    
Assertions.assertTrue(QueryResultPusher.clientAcceptsTrailers(requestWithTe("Trailers")));
+    
Assertions.assertTrue(QueryResultPusher.clientAcceptsTrailers(requestWithTe("gzip;q=0.5,
 trailers")));
+    
Assertions.assertTrue(QueryResultPusher.clientAcceptsTrailers(requestWithTe(" 
trailers ;q=1, gzip")));
+  }
+
+  private static HttpServletRequest requestWithTe(final String te)
+  {
+    final MockHttpServletRequest request = new MockHttpServletRequest();
+    if (te != null) {
+      request.headers.put(HttpHeader.TE.asString(), te);
+    }
+    return request;
+  }
+
   @Test
   public void testResultPusherRetainsNestedExceptionBacktraces()
   {
diff --git 
a/server/src/test/java/org/apache/druid/server/mocks/MockHttpServletRequest.java
 
b/server/src/test/java/org/apache/druid/server/mocks/MockHttpServletRequest.java
index 0582f5871b1..ea7e9865826 100644
--- 
a/server/src/test/java/org/apache/druid/server/mocks/MockHttpServletRequest.java
+++ 
b/server/src/test/java/org/apache/druid/server/mocks/MockHttpServletRequest.java
@@ -37,6 +37,7 @@ import javax.servlet.http.Part;
 import java.io.BufferedReader;
 import java.security.Principal;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Enumeration;
 import java.util.LinkedHashMap;
 import java.util.Locale;
@@ -94,7 +95,8 @@ public class MockHttpServletRequest implements 
HttpServletRequest
   @Override
   public Enumeration<String> getHeaders(String name)
   {
-    throw new UnsupportedOperationException();
+    final String value = headers.get(name);
+    return Collections.enumeration(value == null ? Collections.emptyList() : 
Collections.singletonList(value));
   }
 
   @Override


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to