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

dsmiley pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new 282346f941f SOLR-18312: HttpJdkSolrClient: Fix Thread Pool Starvation 
(#4655)
282346f941f is described below

commit 282346f941fda0987e7b2a0771696c18038d6395
Author: Renato Haeberli <[email protected]>
AuthorDate: Sun Aug 16 22:46:13 2026 +0200

    SOLR-18312: HttpJdkSolrClient: Fix Thread Pool Starvation (#4655)
    
    Fix Thread Pool Starvation in HttpJdkSolrClient.
    
    HttpJdkSolrClient defaults would often create 32 threads, likely 
under-utilizing them. and it would cap threads to 256. Now it does neither by 
default but Executor customization (and other saturation controls) remain.
    
    Co-authored-by: David Smiley <[email protected]>
---
 ...Thread-Pool-Starvation-in-HttpJdkSolrClient.yml |  11 ++
 .../pages/major-changes-in-solr-10.adoc            |   1 +
 .../solr/client/solrj/impl/HttpJdkSolrClient.java  | 159 ++++++++++++---------
 .../client/solrj/impl/HttpJdkSolrClientTest.java   |  49 +++++++
 4 files changed, 152 insertions(+), 68 deletions(-)

diff --git 
a/changelog/unreleased/SOLR-18312-fix-Thread-Pool-Starvation-in-HttpJdkSolrClient.yml
 
b/changelog/unreleased/SOLR-18312-fix-Thread-Pool-Starvation-in-HttpJdkSolrClient.yml
new file mode 100644
index 00000000000..3691824f823
--- /dev/null
+++ 
b/changelog/unreleased/SOLR-18312-fix-Thread-Pool-Starvation-in-HttpJdkSolrClient.yml
@@ -0,0 +1,11 @@
+title: >
+  Fix Thread Pool Starvation in HttpJdkSolrClient.
+  HttpJdkSolrClient defaults would often create 32 threads, likely 
under-utilizing them. and it would cap threads to 256.
+  Now it does neither by default but Executor customization (and other 
saturation controls) remain.
+type: fixed
+authors:
+  - name: Renato Haeberli
+  - name: David Smiley
+links:
+  - name: SOLR-18312
+    url: https://issues.apache.org/jira/browse/SOLR-18312
diff --git 
a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc 
b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
index a065a7413e1..cfa96a93865 100644
--- 
a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
+++ 
b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-10.adoc
@@ -95,6 +95,7 @@ When enabled, this escape hatch relaxes both the login-time 
check and the user c
 It is intended as a temporary measure while you migrate the affected accounts 
to stronger passwords, and should be removed once that is done.
 
 === SolrJ
+The `HttpJdkSolrClient` and `HttpJettySolrClient` no longer have default 
thread/executor limits.  Nonetheless the Executor is configurable.
 
 HttpSolrClient returns; this time as a base class for HttpJettySolrClient and 
HttpJdkSolrClient.
 Its builder will dynamically detect if solr-jetty is available and use that, 
otherwise it will use the JDK client.
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpJdkSolrClient.java 
b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpJdkSolrClient.java
index 111bfd1bc92..722b241cef6 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpJdkSolrClient.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpJdkSolrClient.java
@@ -21,6 +21,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.PipedInputStream;
 import java.io.PipedOutputStream;
+import java.io.UncheckedIOException;
 import java.lang.invoke.MethodHandles;
 import java.net.CookieHandler;
 import java.net.InetSocketAddress;
@@ -37,12 +38,9 @@ import java.util.Collection;
 import java.util.HashMap;
 import java.util.Locale;
 import java.util.Map;
-import java.util.concurrent.BlockingQueue;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.TimeUnit;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import javax.net.ssl.SSLContext;
@@ -68,7 +66,10 @@ import org.slf4j.LoggerFactory;
  * A SolrClient implementation that communicates to a Solr server using the 
built-in Java 11+ Http
  * Client. This client is targeted for those users who wish to minimize 
application dependencies.
  * This client will connect to solr using Http/2 but can seamlessly downgrade 
to Http/1.1 when
- * connecting to Solr hosts running on older versions.
+ * connecting to Solr hosts running on older versions. Uses two {@link
+ * java.util.concurrent.ThreadPoolExecutor}, one for {@link HttpClient} 
(consumer) and one for
+ * writing request bodies (producer). Both are unbounded cached thread pools 
(maximumPoolSize =
+ * Integer.MAX_VALUE).
  */
 public class HttpJdkSolrClient extends HttpSolrClient {
   private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
@@ -78,54 +79,63 @@ public class HttpJdkSolrClient extends HttpSolrClient {
 
   protected HttpClient httpClient;
 
+  /**
+   * Executor used to stream (produce) request bodies into the pipe consumed 
by the JDK HttpClient.
+   */
+  protected ExecutorService requestBodyExecutor;
+
+  /** Dedicated executor handed to the JDK HttpClient */
   protected ExecutorService executor;
 
   private boolean forceHttp11;
 
   private final boolean shutdownExecutor;
 
+  /**
+   * {@link ExecutorService} on {@link HttpJdkSolrClient.Builder} is used for 
{@link HttpClient}
+   * only.
+   */
   protected HttpJdkSolrClient(String serverBaseUrl, HttpJdkSolrClient.Builder 
builder) {
     super(serverBaseUrl, builder);
-    HttpClient.Builder b = HttpClient.newBuilder();
+    HttpClient.Builder httpClientBuilder = HttpClient.newBuilder();
 
     HttpClient.Redirect followRedirects =
         Boolean.TRUE.equals(builder.getFollowRedirects())
             ? HttpClient.Redirect.NORMAL
             : HttpClient.Redirect.NEVER;
-    b.followRedirects(followRedirects);
+    httpClientBuilder.followRedirects(followRedirects);
 
-    b.connectTimeout(Duration.of(builder.getConnectionTimeoutMillis(), 
ChronoUnit.MILLIS));
+    httpClientBuilder.connectTimeout(
+        Duration.of(builder.getConnectionTimeoutMillis(), ChronoUnit.MILLIS));
     // note: idle timeout isn't used for the JDK client
     // note: request timeout is set per request
 
     if (builder.sslContext != null) {
-      b.sslContext(builder.sslContext);
+      httpClientBuilder.sslContext(builder.sslContext);
     }
 
     if (builder.getExecutor() != null) {
       this.executor = builder.getExecutor();
       this.shutdownExecutor = false;
     } else {
-      BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>(1024);
       this.executor =
-          new ExecutorUtil.MDCAwareThreadPoolExecutor(
-              4,
-              256,
-              60,
-              TimeUnit.SECONDS,
-              queue,
+          ExecutorUtil.newMDCAwareCachedThreadPool(
               new SolrNamedThreadFactory(this.getClass().getSimpleName()));
       this.shutdownExecutor = true;
     }
-    b.executor(this.executor);
+    httpClientBuilder.executor(this.executor);
+
+    this.requestBodyExecutor =
+        ExecutorUtil.newMDCAwareCachedThreadPool(
+            new SolrNamedThreadFactory(this.getClass().getSimpleName() + 
"-reqBody"));
 
     if (builder.shouldUseHttp1_1()) {
       this.forceHttp11 = true;
-      b.version(HttpClient.Version.HTTP_1_1);
+      httpClientBuilder.version(HttpClient.Version.HTTP_1_1);
     }
 
     if (builder.cookieHandler != null) {
-      b.cookieHandler(builder.cookieHandler);
+      httpClientBuilder.cookieHandler(builder.cookieHandler);
     }
 
     if (builder.getProxyHost() != null) {
@@ -133,10 +143,10 @@ public class HttpJdkSolrClient extends HttpSolrClient {
         log.warn(
             "Socks4 is likely not supported by this client.  See 
https://bugs.openjdk.org/browse/JDK-8214516";);
       }
-      b.proxy(
+      httpClientBuilder.proxy(
           ProxySelector.of(new InetSocketAddress(builder.getProxyHost(), 
builder.getProxyPort())));
     }
-    this.httpClient = b.build();
+    this.httpClient = httpClientBuilder.build();
 
     assert ObjectReleaseTracker.track(this);
   }
@@ -147,7 +157,7 @@ public class HttpJdkSolrClient extends HttpSolrClient {
       PreparedRequest pReq = prepareRequest(baseUrl, solrRequest, collection);
       return httpClient
           .sendAsync(pReq.reqb.build(), 
HttpResponse.BodyHandlers.ofInputStream())
-          .whenComplete((httpResponse, throwable) -> 
releaseContentWriting(pReq));
+          .whenComplete((httpResponse, throwable) -> 
pReq.releaseContentWriting());
     } catch (Exception e) {
       CompletableFuture<HttpResponse<InputStream>> cf = new 
CompletableFuture<>();
       cf.completeExceptionally(e);
@@ -162,7 +172,7 @@ public class HttpJdkSolrClient extends HttpSolrClient {
       PreparedRequest pReq = prepareRequest(null, solrRequest, collection);
       return httpClient
           .sendAsync(pReq.reqb.build(), 
HttpResponse.BodyHandlers.ofInputStream())
-          .whenComplete((httpResponse, throwable) -> 
releaseContentWriting(pReq))
+          .whenComplete((httpResponse, throwable) -> 
pReq.releaseContentWriting())
           .thenApply(
               httpResponse -> {
                 try {
@@ -179,21 +189,6 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     }
   }
 
-  private void releaseContentWriting(PreparedRequest pReq) {
-    if (pReq.contentWritingFuture != null) {
-      pReq.contentWritingFuture.cancel(true);
-    }
-    // Closing the sink is what unblocks a writer already stuck in the pipe; 
cancel() alone does
-    // not.
-    if (pReq.contentWritingSink != null) {
-      try {
-        pReq.contentWritingSink.close();
-      } catch (IOException e) {
-        log.warn("Could not close content-writing pipe", e);
-      }
-    }
-  }
-
   @Override
   public NamedList<Object> requestWithBaseUrl(
       String baseUrl, SolrRequest<?> solrRequest, String collection)
@@ -214,9 +209,7 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     } catch (RuntimeException e) {
       throw new SolrServerException(e);
     } finally {
-      if (pReq.contentWritingFuture != null) {
-        pReq.contentWritingFuture.cancel(true);
-      }
+      pReq.releaseContentWriting();
 
       // See
       // 
https://docs.oracle.com/en/java/javase/17/docs/api/java.net.http/java/net/http/HttpResponse.BodySubscribers.html#ofInputStream()
@@ -252,7 +245,7 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     ResponseParser parserToUse = responseParser(solrRequest);
     ModifiableSolrParams queryParams = initializeSolrParams(solrRequest, 
parserToUse);
     var reqb = HttpRequest.newBuilder();
-    PreparedRequest pReq = null;
+    PreparedRequest pReq;
     try {
       switch (solrRequest.getMethod()) {
         case GET:
@@ -289,7 +282,7 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     reqb.GET();
     decorateRequest(reqb, solrRequest);
     reqb.uri(new URI(url + queryParams.toQueryString()));
-    return new PreparedRequest(reqb, null, null);
+    return new PreparedRequest(reqb);
   }
 
   private PreparedRequest preparePutOrPost(
@@ -320,28 +313,16 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     }
 
     HttpRequest.BodyPublisher bodyPublisher;
-    Future<?> contentWritingFuture = null;
-    PipedInputStream contentWritingSink = null;
+    PreparedRequest pReq = new PreparedRequest(reqb);
     if (contentWriter != null) {
       boolean success = maybeTryHeadRequest(url);
       if (!success) {
         reqb.version(HttpClient.Version.HTTP_1_1);
       }
 
-      final PipedOutputStream source = new PipedOutputStream();
-      contentWritingSink = new PipedInputStream(source);
-      final PipedInputStream sink = contentWritingSink;
-      bodyPublisher = HttpRequest.BodyPublishers.ofInputStream(() -> sink);
-
-      contentWritingFuture =
-          executor.submit(
-              () -> {
-                try (source) {
-                  contentWriter.write(source);
-                } catch (Exception e) {
-                  log.error("Cannot write Content Stream", e);
-                }
-              });
+      bodyPublisher =
+          HttpRequest.BodyPublishers.ofInputStream(
+              () -> pReq.beginContentWriting(contentWriter, 
this.requestBodyExecutor));
     } else if (streams != null && streams.size() == 1) {
       boolean success = maybeTryHeadRequest(url);
       if (!success) {
@@ -372,25 +353,62 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     URI uriWithQueryParams = new URI(url + queryParams.toQueryString());
     reqb.uri(uriWithQueryParams);
 
-    return new PreparedRequest(reqb, contentWritingFuture, contentWritingSink);
+    return pReq;
   }
 
   protected static class PreparedRequest {
-    Future<?> contentWritingFuture;
-    PipedInputStream contentWritingSink;
-    HttpRequest.Builder reqb;
+    final HttpRequest.Builder reqb;
 
     ResponseParser parserToUse;
 
     String url;
 
-    PreparedRequest(
-        HttpRequest.Builder reqb,
-        Future<?> contentWritingFuture,
-        PipedInputStream contentWritingSink) {
+    // Both remain null if the request has no streamed content, or if the body 
is never requested
+    // (e.g. the connection failed before sending it). Filled in lazily by
+    // beginContentWriting once the JDK HttpClient actually requests the body.
+    private PipedInputStream contentWritingSink;
+    private Future<?> contentWritingFuture;
+
+    PreparedRequest(HttpRequest.Builder reqb) {
       this.reqb = reqb;
-      this.contentWritingFuture = contentWritingFuture;
-      this.contentWritingSink = contentWritingSink;
+    }
+
+    synchronized PipedInputStream beginContentWriting(
+        RequestWriter.ContentWriter contentWriter, ExecutorService 
bodyExecutor) {
+      final PipedOutputStream source = new PipedOutputStream();
+      try {
+        contentWritingSink = new PipedInputStream(source);
+      } catch (IOException e) {
+        throw new UncheckedIOException(e);
+      }
+
+      contentWritingFuture =
+          bodyExecutor.submit(
+              () -> {
+                // note: doesn't need to synchronize with PreparedRequest.this
+                try (source) {
+                  contentWriter.write(source);
+                } catch (Exception e) {
+                  log.error("Cannot write Content Stream", e);
+                }
+              });
+      return contentWritingSink;
+    }
+
+    synchronized void releaseContentWriting() {
+      if (contentWritingFuture != null) {
+        contentWritingFuture.cancel(true);
+      }
+
+      // Closing the sink is what unblocks a writer already stuck in the pipe; 
cancel() alone does
+      // not.
+      if (contentWritingSink != null) {
+        try {
+          contentWritingSink.close();
+        } catch (IOException e) {
+          log.warn("Could not close content-writing pipe", e);
+        }
+      }
     }
   }
 
@@ -545,6 +563,11 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     }
     executor = null;
 
+    if (requestBodyExecutor != null) {
+      ExecutorUtil.shutdownAndAwaitTermination(requestBodyExecutor);
+      requestBodyExecutor = null;
+    }
+
     assert ObjectReleaseTracker.release(this);
   }
 
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpJdkSolrClientTest.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpJdkSolrClientTest.java
index 79bfdb63e9c..9f9233f375e 100644
--- 
a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpJdkSolrClientTest.java
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpJdkSolrClientTest.java
@@ -48,6 +48,7 @@ import org.apache.solr.client.solrj.request.QueryRequest;
 import org.apache.solr.client.solrj.request.SolrQuery;
 import org.apache.solr.client.solrj.request.UpdateRequest;
 import org.apache.solr.client.solrj.request.XMLRequestWriter;
+import org.apache.solr.client.solrj.request.json.JsonQueryRequest;
 import org.apache.solr.client.solrj.response.JavaBinResponseParser;
 import org.apache.solr.client.solrj.response.ResponseParser;
 import org.apache.solr.client.solrj.response.SolrPingResponse;
@@ -637,6 +638,54 @@ public class HttpJdkSolrClientTest extends 
HttpSolrClientTestBase {
     }
   }
 
+  @Test(timeout = 30000)
+  public void testConcurrentStreamedBodiesDoNotDeadlockWithHttp1() throws 
Exception {
+    DebugServlet.clear();
+    DebugServlet.addResponseHeader("Content-Type", "application/octet-stream");
+    DebugServlet.responseBodyByQueryFragment.put("", javabinResponse());
+    String url = solrTestRule.getBaseUrl() + DEBUG_SERVLET_PATH;
+
+    int concurrency = 8;
+    ExecutorService callers =
+        ExecutorUtil.newMDCAwareFixedThreadPool(concurrency, new 
NamedThreadFactory("test-caller"));
+
+    try (HttpJdkSolrClient client = builder(url).useHttp1_1(true).build()) {
+      List<CompletableFuture<Void>> futures = new ArrayList<>(concurrency);
+      for (int i = 0; i < concurrency; i++) {
+        futures.add(
+            CompletableFuture.runAsync(
+                () -> {
+                  JsonQueryRequest q = buildLargeBodyQuery();
+                  try {
+                    q.process(client);
+                  } catch (SolrServerException | IOException e) {
+                    throw new RuntimeException(e);
+                  }
+                },
+                callers));
+      }
+      CompletableFuture.allOf(futures.toArray(new CompletableFuture<?>[0]))
+          .get(30, TimeUnit.SECONDS);
+    } finally {
+      ExecutorUtil.shutdownAndAwaitTermination(callers);
+    }
+  }
+
+  private static JsonQueryRequest buildLargeBodyQuery() {
+    StringBuilder filter = new StringBuilder("id:(");
+    for (int i = 0; i < 400; i++) {
+      if (i > 0) {
+        filter.append(" OR ");
+      }
+      filter.append("value_").append(i);
+    }
+    filter.append(')');
+    JsonQueryRequest q = new JsonQueryRequest();
+    q.setQuery("*:*");
+    q.withFilter(filter.toString());
+    return q;
+  }
+
   /**
    * This is not required for any test, but there appears to be a bug in the 
JDK client where it
    * does not release all threads if the client has not performed any queries, 
even after a forced

Reply via email to