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

kgyrtkirk 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 bb50f29b1d7 fix: ignore TrafficCop calls once the connection is back 
in the pool (#20522)
bb50f29b1d7 is described below

commit bb50f29b1d792c7b82e48dd24435be5966e8b269
Author: Zoltan Haindrich <[email protected]>
AuthorDate: Sun Oct 11 09:07:39 2026 +0200

    fix: ignore TrafficCop calls once the connection is back in the pool 
(#20522)
---
 .../java/util/http/client/NettyHttpClient.java     |  25 ++-
 .../http/client/response/HttpResponseHandler.java  |   4 +
 .../java/util/http/client/FriendlyServersTest.java | 201 +++++++++++++++++++++
 .../org/apache/druid/client/DirectDruidClient.java |  27 +--
 4 files changed, 233 insertions(+), 24 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 ff4f64af394..7455eb487b5 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
@@ -240,6 +240,7 @@ public class NettyHttpClient extends AbstractHttpClient
           private final Object watermarkLock = new Object();
           private long suspendWatermark = -1;
           private long resumeWatermark = -1;
+          private boolean returnedToPool = false;
 
           @Override
           protected void channelRead0(ChannelHandlerContext ctx, HttpObject 
msg)
@@ -274,6 +275,9 @@ public class NettyHttpClient extends AbstractHttpClient
                   public long resume(long resumeChunkNum)
                   {
                     synchronized (watermarkLock) {
+                      if (returnedToPool) {
+                        return 0;
+                      }
                       resumeWatermark = Math.max(resumeWatermark, 
resumeChunkNum);
 
                       if (suspendWatermark >= 0 && resumeWatermark >= 
suspendWatermark) {
@@ -292,8 +296,12 @@ public class NettyHttpClient extends AbstractHttpClient
                   @Override
                   public void abort()
                   {
-                    log.debug("[%s] Aborted connection at caller's request.", 
requestDesc);
-                    channel.close();
+                    // On the event loop, so that it is ordered against 
finishRequest handing the channel back.
+                    if (channel.eventLoop().inEventLoop()) {
+                      closeUnlessReturnedToPool();
+                    } else {
+                      channel.eventLoop().execute(() -> 
closeUnlessReturnedToPool());
+                    }
                   }
                 };
 
@@ -388,9 +396,22 @@ public class NettyHttpClient extends AbstractHttpClient
             }
             removeHandlers();
             channel.config().setAutoRead(true);
+            synchronized (watermarkLock) {
+              returnedToPool = true;
+            }
             channelResourceContainer.returnResource();
           }
 
+          private void closeUnlessReturnedToPool()
+          {
+            synchronized (watermarkLock) {
+              if (!returnedToPool) {
+                log.debug("[%s] Aborted connection at caller's request.", 
requestDesc);
+                channel.close();
+              }
+            }
+          }
+
           @Override
           public void exceptionCaught(ChannelHandlerContext context, Throwable 
cause)
           {
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 4b8127b2d92..cc880046a4e 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
@@ -87,6 +87,10 @@ public interface HttpResponseHandler<IntermediateType, 
FinalType>
 
   void exceptionCaught(ClientResponse<IntermediateType> clientResponse, 
Throwable e);
 
+  /**
+   * Flow control over the connection carrying one response. Once that 
response has completed, calls have no effect:
+   * the connection may already be carrying the response of another request.
+   */
   interface TrafficCop
   {
     /**
diff --git 
a/processing/src/test/java/org/apache/druid/java/util/http/client/FriendlyServersTest.java
 
b/processing/src/test/java/org/apache/druid/java/util/http/client/FriendlyServersTest.java
index 098843c7d42..629174b2685 100644
--- 
a/processing/src/test/java/org/apache/druid/java/util/http/client/FriendlyServersTest.java
+++ 
b/processing/src/test/java/org/apache/druid/java/util/http/client/FriendlyServersTest.java
@@ -32,6 +32,8 @@ import 
org.apache.druid.java.util.http.client.response.ClientResponse;
 import org.apache.druid.java.util.http.client.response.HttpResponseHandler;
 import org.apache.druid.java.util.http.client.response.StatusResponseHandler;
 import org.apache.druid.java.util.http.client.response.StatusResponseHolder;
+import 
org.apache.druid.java.util.http.client.response.StringFullResponseHandler;
+import 
org.apache.druid.java.util.http.client.response.StringFullResponseHolder;
 import org.eclipse.jetty.server.Connector;
 import org.eclipse.jetty.server.HttpConfiguration;
 import org.eclipse.jetty.server.HttpConnectionFactory;
@@ -58,11 +60,13 @@ import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
 import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
 
@@ -71,6 +75,9 @@ import java.util.concurrent.atomic.AtomicReference;
  */
 public class FriendlyServersTest
 {
+  private static final String CHUNKED_OK = "HTTP/1.1 200 
OK\r\nTransfer-Encoding: chunked\r\n\r\n";
+  private static final String LAST_CHUNK = "0\r\n\r\n";
+
   @Test
   public void testFriendlyHttpServer() throws Exception
   {
@@ -753,4 +760,198 @@ public class FriendlyServersTest
       lifecycle.stop();
     }
   }
+
+  /**
+   * An abort that arrives once the response is complete must not close the 
channel: it is back in the pool, and the
+   * next request may already be on it.
+   */
+  @Test
+  public void testAbortAfterCompletionLeavesThePooledChannelOpen() throws 
Exception
+  {
+    final ExecutorService exec = Executors.newSingleThreadExecutor();
+    final ServerSocket serverSocket = new ServerSocket(0);
+    final BlockingQueue<String> wire = new LinkedBlockingQueue<>();
+    serveChunkedFromWire(exec, serverSocket, wire);
+
+    final Lifecycle lifecycle = new Lifecycle();
+    try {
+      final HttpClient client = HttpClientInit.createClient(
+          HttpClientConfig.builder().withNumConnections(1).build(),
+          lifecycle
+      );
+      final Request request = new Request(
+          HttpMethod.GET,
+          new URL(StringUtils.format("http://localhost:%d/";, 
serverSocket.getLocalPort()))
+      );
+
+      final CapturingTrafficCop<StringFullResponseHolder, 
StringFullResponseHolder> first =
+          new CapturingTrafficCop<>(new 
StringFullResponseHandler(StandardCharsets.UTF_8));
+      final ListenableFuture<StringFullResponseHolder> firstResponse = 
client.go(request, first);
+      wire.put(CHUNKED_OK + chunk("a") + LAST_CHUNK);
+      Assertions.assertEquals("a", firstResponse.get(30, 
TimeUnit.SECONDS).getContent());
+
+      first.trafficCop.get().abort();
+
+      // The server answers on its only connection, so the second request 
succeeds only if it reuses the first's.
+      final ListenableFuture<StringFullResponseHolder> secondResponse =
+          client.go(request, new 
StringFullResponseHandler(StandardCharsets.UTF_8));
+      wire.put(CHUNKED_OK + chunk("b") + LAST_CHUNK);
+      Assertions.assertEquals("b", secondResponse.get(30, 
TimeUnit.SECONDS).getContent());
+    }
+    finally {
+      exec.shutdownNow();
+      serverSocket.close();
+      lifecycle.stop();
+    }
+  }
+
+  /**
+   * A resume that arrives once the response is complete must not touch the 
channel: it is back in the pool, and the
+   * next request on it may have suspended reads.
+   */
+  @Test
+  public void testResumeAfterCompletionLeavesTheNextRequestSuspended() throws 
Exception
+  {
+    final ExecutorService exec = Executors.newSingleThreadExecutor();
+    final ServerSocket serverSocket = new ServerSocket(0);
+    final BlockingQueue<String> wire = new LinkedBlockingQueue<>();
+    serveChunkedFromWire(exec, serverSocket, wire);
+
+    final Lifecycle lifecycle = new Lifecycle();
+    try {
+      final HttpClient client = HttpClientInit.createClient(
+          HttpClientConfig.builder().withNumConnections(1).build(),
+          lifecycle
+      );
+      final Request request = new Request(
+          HttpMethod.GET,
+          new URL(StringUtils.format("http://localhost:%d/";, 
serverSocket.getLocalPort()))
+      );
+
+      // The first request suspends too: only then does its late resume pass 
its own watermark and reach the channel.
+      final SuspendAfterFirstChunk<StringFullResponseHolder, 
StringFullResponseHolder> first =
+          new SuspendAfterFirstChunk<>(new 
StringFullResponseHandler(StandardCharsets.UTF_8));
+      final ListenableFuture<StringFullResponseHolder> firstResponse = 
client.go(request, first);
+      wire.put(CHUNKED_OK + chunk("a") + LAST_CHUNK);
+      Assertions.assertEquals("a", firstResponse.get(30, 
TimeUnit.SECONDS).getContent());
+
+      final SuspendAfterFirstChunk<StringFullResponseHolder, 
StringFullResponseHolder> second =
+          new SuspendAfterFirstChunk<>(new 
StringFullResponseHandler(StandardCharsets.UTF_8));
+      final ListenableFuture<StringFullResponseHolder> secondResponse = 
client.go(request, second);
+      wire.put(CHUNKED_OK + chunk("b"));
+      Assertions.assertTrue(second.suspended.await(30, TimeUnit.SECONDS));
+
+      first.trafficCop.get().resume(1);
+      wire.put(chunk("c") + LAST_CHUNK);
+      Assertions.assertThrows(TimeoutException.class, () -> 
secondResponse.get(1, TimeUnit.SECONDS));
+
+      second.trafficCop.get().resume(1);
+      Assertions.assertEquals("bc", secondResponse.get(30, 
TimeUnit.SECONDS).getContent());
+    }
+    finally {
+      exec.shutdownNow();
+      serverSocket.close();
+      lifecycle.stop();
+    }
+  }
+
+  private static String chunk(String data)
+  {
+    return StringUtils.format("%x\r\n%s\r\n", data.length(), data);
+  }
+
+  /**
+   * Serves one keep-alive connection. Each request is answered with the 
segments taken from {@code wire}, up to the
+   * one that ends the chunked body, so the test decides when each part of a 
response goes out.
+   */
+  private static void serveChunkedFromWire(ExecutorService exec, ServerSocket 
serverSocket, BlockingQueue<String> wire)
+  {
+    exec.submit(
+        () -> {
+          try (
+              Socket s = serverSocket.accept();
+              BufferedReader in = new BufferedReader(new 
InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
+              OutputStream out = s.getOutputStream()
+          ) {
+            String line;
+            while ((line = in.readLine()) != null) {
+              if (line.isEmpty()) {
+                String segment;
+                do {
+                  segment = wire.take();
+                  out.write(StringUtils.toUtf8(segment));
+                  out.flush();
+                } while (!segment.endsWith(LAST_CHUNK));
+              }
+            }
+          }
+          catch (Exception ignored) {
+            // suppress
+          }
+        }
+    );
+  }
+
+  /**
+   * Exposes the {@link HttpResponseHandler.TrafficCop} of the response it 
handles.
+   */
+  private static class CapturingTrafficCop<I, F> implements 
HttpResponseHandler<I, F>
+  {
+    final HttpResponseHandler<I, F> delegate;
+    final AtomicReference<TrafficCop> trafficCop = new AtomicReference<>();
+
+    CapturingTrafficCop(HttpResponseHandler<I, F> delegate)
+    {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public ClientResponse<I> handleResponse(HttpResponse response, TrafficCop 
trafficCop)
+    {
+      this.trafficCop.set(trafficCop);
+      return delegate.handleResponse(response, trafficCop);
+    }
+
+    @Override
+    public ClientResponse<I> handleChunk(ClientResponse<I> clientResponse, 
HttpContent chunk, long chunkNum)
+    {
+      return delegate.handleChunk(clientResponse, chunk, chunkNum);
+    }
+
+    @Override
+    public ClientResponse<F> done(ClientResponse<I> clientResponse)
+    {
+      return delegate.done(clientResponse);
+    }
+
+    @Override
+    public void exceptionCaught(ClientResponse<I> clientResponse, Throwable e)
+    {
+      delegate.exceptionCaught(clientResponse, e);
+    }
+  }
+
+  /**
+   * Suspends reads after the first chunk; resume them through {@link 
#trafficCop}.
+   */
+  private static class SuspendAfterFirstChunk<I, F> extends 
CapturingTrafficCop<I, F>
+  {
+    final CountDownLatch suspended = new CountDownLatch(1);
+
+    SuspendAfterFirstChunk(HttpResponseHandler<I, F> delegate)
+    {
+      super(delegate);
+    }
+
+    @Override
+    public ClientResponse<I> handleChunk(ClientResponse<I> clientResponse, 
HttpContent chunk, long chunkNum)
+    {
+      final ClientResponse<I> next = super.handleChunk(clientResponse, chunk, 
chunkNum);
+      if (chunkNum != 1) {
+        return next;
+      }
+      suspended.countDown();
+      return new ClientResponse<>(next.isFinished(), false, next.getObj());
+    }
+  }
 }
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 2f963e0b270..858930cc0d0 100644
--- a/server/src/main/java/org/apache/druid/client/DirectDruidClient.java
+++ b/server/src/main/java/org/apache/druid/client/DirectDruidClient.java
@@ -24,7 +24,6 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.dataformat.smile.SmileConstants;
 import com.fasterxml.jackson.dataformat.smile.SmileFactory;
 import com.fasterxml.jackson.jaxrs.smile.SmileMediaTypes;
-import com.google.common.base.Preconditions;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
@@ -222,8 +221,6 @@ public class DirectDruidClient<T> implements QueryRunner<T>
           }
         }
 
-        private final AtomicReference<TrafficCop> trafficCopRef = new 
AtomicReference<>();
-
         private QueryMetrics<? super Query<T>> queryMetrics;
         private long responseStartTimeNs;
 
@@ -257,7 +254,7 @@ public class DirectDruidClient<T> implements QueryRunner<T>
           return !usingBackpressure || currentQueuedByteCount < maxQueuedBytes;
         }
 
-        private InputStream dequeue() throws InterruptedException
+        private InputStream dequeue(TrafficCop trafficCop) throws 
InterruptedException
         {
           final InputStreamHolder holder = queue.poll(checkQueryTimeout(), 
TimeUnit.MILLISECONDS);
           if (holder == null) {
@@ -266,8 +263,7 @@ public class DirectDruidClient<T> implements QueryRunner<T>
 
           final long currentQueuedByteCount = 
queuedByteCount.addAndGet(-holder.getLength());
           if (usingBackpressure && currentQueuedByteCount < maxQueuedBytes) {
-            long backPressureTime = 
Preconditions.checkNotNull(trafficCopRef.get(), "No TrafficCop, how can this 
be?")
-                                                 .resume(holder.getChunkNum());
+            long backPressureTime = trafficCop.resume(holder.getChunkNum());
             channelSuspendedTime.addAndGet(backPressureTime);
           }
 
@@ -424,7 +420,6 @@ public class DirectDruidClient<T> implements QueryRunner<T>
         @Override
         public ClientResponse<InputStream> handleResponse(HttpResponse 
response, TrafficCop trafficCop)
         {
-          trafficCopRef.set(trafficCop);
           checkQueryTimeout();
           // Netty 4: the initial HttpResponse carries no body, so the status 
and Content-Type are recorded
           // here and the body itself is inspected on the first HttpContent 
chunk. The goal is to detect a
@@ -511,7 +506,7 @@ public class DirectDruidClient<T> implements QueryRunner<T>
                       }
 
                       try {
-                        return dequeue();
+                        return dequeue(trafficCop);
                       }
                       catch (InterruptedException e) {
                         Thread.currentThread().interrupt();
@@ -529,20 +524,8 @@ public class DirectDruidClient<T> implements QueryRunner<T>
                 @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;
-                  }
+                  discard.set(true);
+                  queue.clear();
                   trafficCop.abort();
                 }
               },


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

Reply via email to