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

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


The following commit(s) were added to refs/heads/master by this push:
     new 14df9842a2d fix: Terminate connection on closing the stream from the 
DirectDruidClient (#19607)
14df9842a2d is described below

commit 14df9842a2d6fe7bf514ea3b4fb5a7cf3c7c68cc
Author: Laksh Singla <[email protected]>
AuthorDate: Fri Jun 26 06:35:53 2026 +0530

    fix: Terminate connection on closing the stream from the DirectDruidClient 
(#19607)
    
    * init
    
    * comment
    
    * empty
---
 .../java/util/http/client/NettyHttpClient.java     |  35 ++--
 .../http/client/response/HttpResponseHandler.java  |   8 +
 .../org/apache/druid/client/DirectDruidClient.java |  43 ++++-
 .../client/DirectDruidClientAbortHttpTest.java     | 184 +++++++++++++++++++++
 .../org/apache/druid/client/TestHttpClient.java    |  15 +-
 .../org/apache/druid/rpc/MockServiceClient.java    |   3 +-
 .../simulate/TestSegmentLoadingHttpClient.java     |   8 +-
 7 files changed, 278 insertions(+), 18 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/java/util/http/client/NettyHttpClient.java
 
b/processing/src/main/java/org/apache/druid/java/util/http/client/NettyHttpClient.java
index 3ab3719180f..76a50a71127 100644
--- 
a/processing/src/main/java/org/apache/druid/java/util/http/client/NettyHttpClient.java
+++ 
b/processing/src/main/java/org/apache/druid/java/util/http/client/NettyHttpClient.java
@@ -219,21 +219,34 @@ public class NettyHttpClient extends AbstractHttpClient
                   log.debug("[%s] Got response: %s", requestDesc, 
httpResponse.getStatus());
                 }
 
-                HttpResponseHandler.TrafficCop trafficCop = resumeChunkNum -> {
-                  synchronized (watermarkLock) {
-                    resumeWatermark = Math.max(resumeWatermark, 
resumeChunkNum);
-
-                    if (suspendWatermark >= 0 && resumeWatermark >= 
suspendWatermark) {
-                      suspendWatermark = -1;
-                      channel.setReadable(true);
-                      long backPressureDuration = System.nanoTime() - 
backPressureStartTimeNs;
-                      log.debug("[%s] Resumed reads from channel (chunkNum = 
%,d).", requestDesc, resumeChunkNum);
-                      return backPressureDuration;
+                HttpResponseHandler.TrafficCop trafficCop = new 
HttpResponseHandler.TrafficCop()
+                {
+                  @Override
+                  public long resume(long resumeChunkNum)
+                  {
+                    synchronized (watermarkLock) {
+                      resumeWatermark = Math.max(resumeWatermark, 
resumeChunkNum);
+
+                      if (suspendWatermark >= 0 && resumeWatermark >= 
suspendWatermark) {
+                        suspendWatermark = -1;
+                        channel.setReadable(true);
+                        long backPressureDuration = System.nanoTime() - 
backPressureStartTimeNs;
+                        log.debug("[%s] Resumed reads from channel (chunkNum = 
%,d).", requestDesc, resumeChunkNum);
+                        return backPressureDuration;
+                      }
                     }
+
+                    return 0; //If we didn't resume, don't know if 
backpressure was happening
                   }
 
-                  return 0; //If we didn't resume, don't know if backpressure 
was happening
+                  @Override
+                  public void abort()
+                  {
+                    log.debug("[%s] Aborted connection at caller's request.", 
requestDesc);
+                    channel.close();
+                  }
                 };
+
                 response = handler.handleResponse(httpResponse, trafficCop);
                 if (response.isFinished()) {
                   retVal.set((Final) response.getObj());
diff --git 
a/processing/src/main/java/org/apache/druid/java/util/http/client/response/HttpResponseHandler.java
 
b/processing/src/main/java/org/apache/druid/java/util/http/client/response/HttpResponseHandler.java
index 43bca7ace1d..10229176295 100644
--- 
a/processing/src/main/java/org/apache/druid/java/util/http/client/response/HttpResponseHandler.java
+++ 
b/processing/src/main/java/org/apache/druid/java/util/http/client/response/HttpResponseHandler.java
@@ -98,5 +98,13 @@ public interface HttpResponseHandler<IntermediateType, 
FinalType>
      * @return time that backpressure was applied (channel was closed for 
reads)
      */
     long resume(long chunkNum);
+
+    /**
+     * Closes the underlying connection, abandoning any remaining response.
+     *
+     * Intended for callers that decide they no longer need the rest of the 
response (for example, because the
+     * consumer of the resulting stream has been closed early)
+     */
+    void abort();
   }
 }
diff --git 
a/server/src/main/java/org/apache/druid/client/DirectDruidClient.java 
b/server/src/main/java/org/apache/druid/client/DirectDruidClient.java
index 3c746e41070..336bee51012 100644
--- a/server/src/main/java/org/apache/druid/client/DirectDruidClient.java
+++ b/server/src/main/java/org/apache/druid/client/DirectDruidClient.java
@@ -181,6 +181,9 @@ public class DirectDruidClient<T> implements QueryRunner<T>
         private final AtomicLong channelSuspendedTime = new AtomicLong(0);
         private final BlockingQueue<InputStreamHolder> queue = new 
LinkedBlockingQueue<>();
         private final AtomicBoolean done = new AtomicBoolean(false);
+        // Set when the consumer closes the result stream early (see the 
SequenceInputStream.close() override in
+        // handleResponse). Once set, incoming chunks are dropped rather than 
buffered.
+        private final AtomicBoolean discard = new AtomicBoolean(false);
         private final AtomicBoolean nodeMetricsEmitted = new 
AtomicBoolean(false);
         private final AtomicReference<String> fail = new AtomicReference<>();
         private final AtomicReference<TrafficCop> trafficCopRef = new 
AtomicReference<>();
@@ -202,6 +205,12 @@ public class DirectDruidClient<T> implements QueryRunner<T>
          */
         private boolean enqueue(ChannelBuffer buffer, long chunkNum) throws 
InterruptedException
         {
+          // If the consumer has abandoned the response (see the 
SequenceInputStream.close() override below), drop the
+          // chunk instead of buffering it, and keep reads flowing 
(continueReading = true) so we never suspend the
+          // channel while it is being wound down.
+          if (discard.get()) {
+            return true;
+          }
           // Increment queuedByteCount before queueing the object, so 
queuedByteCount is at least as high as
           // the actual number of queued bytes at any particular time.
           final InputStreamHolder holder = 
InputStreamHolder.fromChannelBuffer(buffer, chunkNum);
@@ -282,6 +291,12 @@ public class DirectDruidClient<T> implements QueryRunner<T>
                     @Override
                     public boolean hasMoreElements()
                     {
+                      // If the consumer abandoned the stream (close() ran), 
report end-of-stream. After discard is set
+                      // enqueue() drops every chunk, so a further read would 
otherwise block in dequeue() until the
+                      // query timeout and then throw a misleading 
QueryTimeoutException.
+                      if (discard.get()) {
+                        return false;
+                      }
                       if (fail.get() != null) {
                         throw new RE(fail.get());
                       }
@@ -310,7 +325,33 @@ public class DirectDruidClient<T> implements QueryRunner<T>
                       }
                     }
                   }
-              ),
+              )
+              {
+                /**
+                 * Closing this stream means the caller no longer needs the 
response. The default
+                 * {@link SequenceInputStream#close()} would drain the entire 
remaining response off the wire first
+                 * we want to avoid. Instead, abandon the response and 
force-close the connection
+                 */
+                @Override
+                public void close()
+                {
+                  final TrafficCop trafficCop;
+                  synchronized (done) {
+                    if (done.get()) {
+                      return;
+                    }
+                    // Stop buffering further chunks (see enqueue()) and drop 
anything already buffered so the
+                    // underlying Netty ChannelBuffers can be released.
+                    discard.set(true);
+                    queue.clear();
+                    trafficCop = trafficCopRef.get();
+                  }
+                  if (trafficCop == null) {
+                    return;
+                  }
+                  trafficCop.abort();
+                }
+              },
               continueReading
           );
         }
diff --git 
a/server/src/test/java/org/apache/druid/client/DirectDruidClientAbortHttpTest.java
 
b/server/src/test/java/org/apache/druid/client/DirectDruidClientAbortHttpTest.java
new file mode 100644
index 00000000000..051c058f959
--- /dev/null
+++ 
b/server/src/test/java/org/apache/druid/client/DirectDruidClientAbortHttpTest.java
@@ -0,0 +1,184 @@
+/*
+ * 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.client;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.collect.ImmutableMap;
+import org.apache.druid.common.utils.SocketUtil;
+import org.apache.druid.error.DruidException;
+import org.apache.druid.java.util.common.DateTimes;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.java.util.common.concurrent.Execs;
+import org.apache.druid.java.util.common.guava.Sequence;
+import org.apache.druid.java.util.common.guava.Yielder;
+import org.apache.druid.java.util.common.guava.Yielders;
+import org.apache.druid.java.util.common.io.Closer;
+import org.apache.druid.java.util.common.lifecycle.Lifecycle;
+import org.apache.druid.java.util.http.client.HttpClientConfig;
+import org.apache.druid.java.util.http.client.HttpClientInit;
+import org.apache.druid.query.BaseQuery;
+import org.apache.druid.query.Druids;
+import org.apache.druid.query.QueryPlus;
+import org.apache.druid.query.QueryRunnerFactoryConglomerate;
+import org.apache.druid.query.QueryRunnerTestHelper;
+import org.apache.druid.query.Result;
+import org.apache.druid.segment.TestHelper;
+import org.apache.druid.server.QueryStackTests;
+import org.apache.druid.server.metrics.NoopServiceEmitter;
+import org.eclipse.jetty.ee8.servlet.ServletContextHandler;
+import org.eclipse.jetty.ee8.servlet.ServletHolder;
+import org.eclipse.jetty.server.Server;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class DirectDruidClientAbortHttpTest
+{
+  @Test
+  public void testEarlyStreamClose() throws Exception
+  {
+    final ObjectMapper mapper = TestHelper.makeJsonMapper();
+
+    final AtomicBoolean serverSawDisconnect = new AtomicBoolean(false);
+    final CountDownLatch responseSent = new CountDownLatch(1);
+    final CountDownLatch connectionTerminated = new CountDownLatch(1);
+    final CountDownLatch terminationDetected = new CountDownLatch(1);
+
+    final int port = SocketUtil.findOpenPort(0);
+    final Server server = new Server(port);
+    final ServletContextHandler handler = new ServletContextHandler();
+    handler.addServlet(
+        new ServletHolder(new HttpServlet()
+        {
+          @Override
+          protected void doPost(HttpServletRequest req, HttpServletResponse 
resp)
+          {
+            final ServletOutputStream out;
+            try {
+              out = resp.getOutputStream();
+              resp.setStatus(HttpServletResponse.SC_OK);
+              
out.write(StringUtils.toUtf8("[{\"timestamp\":\"2014-01-01T01:02:03Z\", 
\"result\": 42.0}]"));
+              out.flush();
+            }
+            catch (Exception e) {
+              throw DruidException.defensive(e, "Encountered some issue while 
sending the real bytes");
+            }
+
+            responseSent.countDown();
+
+            try {
+              connectionTerminated.await();
+              final long endTime = System.currentTimeMillis() + 100;
+              while (System.currentTimeMillis() < endTime) {
+                out.write(0);
+                out.flush();
+              }
+            }
+            catch (IOException e) {
+              serverSawDisconnect.set(true);
+              terminationDetected.countDown();
+            }
+            catch (Exception e) {
+              throw DruidException.defensive(
+                  e,
+                  "Encountered some issue while awaiting for the connection to 
be terminated"
+              );
+            }
+          }
+        }),
+        "/*"
+    );
+    server.setHandler(handler);
+
+    final Lifecycle lifecycle = new Lifecycle();
+    final ScheduledExecutorService queryCancellationExecutor =
+        
Execs.scheduledSingleThreaded("DirectDruidClientAbortHttpTest-cancel-%d");
+    final Closer closer = Closer.create();
+    try {
+      server.start();
+      lifecycle.start();
+
+      final QueryRunnerFactoryConglomerate conglomerate =
+          QueryStackTests.createQueryRunnerFactoryConglomerate(closer);
+
+      final DirectDruidClient directDruidClient = new DirectDruidClient(
+          conglomerate,
+          QueryRunnerTestHelper.NOOP_QUERYWATCHER,
+          mapper,
+          HttpClientInit.createClient(
+              HttpClientConfig.builder().withNumConnections(1).build(),
+              lifecycle
+          ),
+          "http",
+          "localhost:" + port,
+          new NoopServiceEmitter(),
+          queryCancellationExecutor
+      );
+
+      final Map<String, Object> queryContext = ImmutableMap.of(
+          DirectDruidClient.QUERY_FAIL_TIME, System.currentTimeMillis() + 
60_000L,
+          BaseQuery.QUERY_ID, "abort-test"
+      );
+
+      final Sequence results = directDruidClient.run(
+          
QueryPlus.wrap(Druids.newTimeBoundaryQueryBuilder().dataSource("test").context(queryContext).build()),
+          DirectDruidClient.makeResponseContextForQuery()
+      );
+
+      responseSent.await();
+
+      final AtomicInteger resultCount = new AtomicInteger(0);
+      final Yielder yielder = Yielders.each(results);
+      try {
+        Assertions.assertFalse(yielder.isDone(), "expected at least one result 
before stopping");
+        final Result result = (Result) yielder.get();
+        Assertions.assertEquals(DateTimes.of("2014-01-01T01:02:03Z"), 
result.getTimestamp());
+        resultCount.incrementAndGet();
+      }
+      finally {
+        connectionTerminated.countDown();
+        yielder.close();
+      }
+
+      Assertions.assertEquals(1, resultCount.get(), "expected exactly one 
result before stopping");
+      if (!terminationDetected.await(5, TimeUnit.SECONDS)) {
+        Assertions.fail("Test did not complete in 5 seconds!?");
+      }
+      Assertions.assertTrue(serverSawDisconnect.get(), "server should have 
marked the connection as disconnected");
+    }
+    finally {
+      queryCancellationExecutor.shutdownNow();
+      closer.close();
+      lifecycle.stop();
+      server.stop();
+    }
+  }
+}
diff --git a/server/src/test/java/org/apache/druid/client/TestHttpClient.java 
b/server/src/test/java/org/apache/druid/client/TestHttpClient.java
index ef4da264aad..4116bc5e16f 100644
--- a/server/src/test/java/org/apache/druid/client/TestHttpClient.java
+++ b/server/src/test/java/org/apache/druid/client/TestHttpClient.java
@@ -62,7 +62,20 @@ import java.util.Map;
  */
 public class TestHttpClient implements HttpClient
 {
-  private static final TrafficCop NOOP_TRAFFIC_COP = checkNum -> 0L;
+  public static final TrafficCop NOOP_TRAFFIC_COP = new TrafficCop()
+  {
+    @Override
+    public long resume(long chunkNum)
+    {
+      return 0;
+    }
+
+    @Override
+    public void abort()
+    {
+
+    }
+  };
   private static final int RESPONSE_CTX_HEADER_LEN_LIMIT = 7 * 1024;
 
   private final Map<URL, SimpleServerManager> servers = new HashMap<>();
diff --git a/server/src/test/java/org/apache/druid/rpc/MockServiceClient.java 
b/server/src/test/java/org/apache/druid/rpc/MockServiceClient.java
index 021db219d96..4dacf0e62ee 100644
--- a/server/src/test/java/org/apache/druid/rpc/MockServiceClient.java
+++ b/server/src/test/java/org/apache/druid/rpc/MockServiceClient.java
@@ -21,6 +21,7 @@ package org.apache.druid.rpc;
 
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
+import org.apache.druid.client.TestHttpClient;
 import org.apache.druid.java.util.common.Either;
 import org.apache.druid.java.util.http.client.response.ClientResponse;
 import org.apache.druid.java.util.http.client.response.HttpResponseHandler;
@@ -60,7 +61,7 @@ public class MockServiceClient implements ServiceClient
 
     if (expectation.response.isValue()) {
       final ClientResponse<FinalType> response =
-          
handler.done(handler.handleResponse(expectation.response.valueOrThrow(), 
chunkNum -> 0));
+          
handler.done(handler.handleResponse(expectation.response.valueOrThrow(), 
TestHttpClient.NOOP_TRAFFIC_COP));
       return Futures.immediateFuture(response.getObj());
     } else {
       return Futures.immediateFailedFuture(expectation.response.error());
diff --git 
a/server/src/test/java/org/apache/druid/server/coordinator/simulate/TestSegmentLoadingHttpClient.java
 
b/server/src/test/java/org/apache/druid/server/coordinator/simulate/TestSegmentLoadingHttpClient.java
index ae911c4d8ae..eda96d28b0b 100644
--- 
a/server/src/test/java/org/apache/druid/server/coordinator/simulate/TestSegmentLoadingHttpClient.java
+++ 
b/server/src/test/java/org/apache/druid/server/coordinator/simulate/TestSegmentLoadingHttpClient.java
@@ -25,6 +25,7 @@ import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningScheduledExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.SettableFuture;
+import org.apache.druid.client.TestHttpClient;
 import org.apache.druid.java.util.http.client.HttpClient;
 import org.apache.druid.java.util.http.client.Request;
 import org.apache.druid.java.util.http.client.response.HttpResponseHandler;
@@ -52,7 +53,6 @@ import java.util.stream.Collectors;
 
 public class TestSegmentLoadingHttpClient implements HttpClient
 {
-  private static final HttpResponseHandler.TrafficCop NOOP_TRAFFIC_COP = 
checkNum -> 0L;
   private static final DataSegmentChangeCallback NOOP_CALLBACK = () -> {
   };
 
@@ -107,7 +107,7 @@ public class TestSegmentLoadingHttpClient implements 
HttpClient
         final HttpResponse failureResponse =
             new DefaultHttpResponse(HttpVersion.HTTP_1_1, 
HttpResponseStatus.NOT_FOUND);
         failureResponse.setContent(ChannelBuffers.EMPTY_BUFFER);
-        handler.handleResponse(failureResponse, NOOP_TRAFFIC_COP);
+        handler.handleResponse(failureResponse, 
TestHttpClient.NOOP_TRAFFIC_COP);
         return (Final) new ByteArrayInputStream(new byte[0]);
       }
 
@@ -122,7 +122,7 @@ public class TestSegmentLoadingHttpClient implements 
HttpClient
       final HttpResponse response =
           new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
       response.setContent(ChannelBuffers.EMPTY_BUFFER);
-      handler.handleResponse(response, NOOP_TRAFFIC_COP);
+      handler.handleResponse(response, TestHttpClient.NOOP_TRAFFIC_COP);
       return (Final) new ByteArrayInputStream(serializedContent);
     }
     catch (Exception e) {
@@ -157,7 +157,7 @@ public class TestSegmentLoadingHttpClient implements 
HttpClient
       // Set response content and status
       final HttpResponse response = new 
DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
       response.setContent(ChannelBuffers.EMPTY_BUFFER);
-      handler.handleResponse(response, NOOP_TRAFFIC_COP);
+      handler.handleResponse(response, TestHttpClient.NOOP_TRAFFIC_COP);
 
       // Serialize
       SettableFuture future = SettableFuture.create();


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

Reply via email to