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

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


The following commit(s) were added to refs/heads/branch_10x by this push:
     new bfd808924e0 SOLR-18244: Fix several concurrency bugs in 
ParallelHttpShardHandler (#4548)
bfd808924e0 is described below

commit bfd808924e098b8f7340969297b5cfe1ab45ccfb
Author: Mark Robert Miller <[email protected]>
AuthorDate: Wed Jun 24 11:29:32 2026 -0500

    SOLR-18244: Fix several concurrency bugs in ParallelHttpShardHandler (#4548)
---
 ...parallel-http-shard-handler-lost-wakeup-fix.yml |  10 +
 .../solr/handler/component/HttpShardHandler.java   | 104 +++++--
 .../component/ParallelHttpShardHandler.java        | 198 ++++++++++---
 .../component/ParallelHttpShardHandlerTest.java    | 307 ++++++++++++++++++++-
 4 files changed, 563 insertions(+), 56 deletions(-)

diff --git 
a/changelog/unreleased/SOLR-18244-parallel-http-shard-handler-lost-wakeup-fix.yml
 
b/changelog/unreleased/SOLR-18244-parallel-http-shard-handler-lost-wakeup-fix.yml
new file mode 100644
index 00000000000..96706027bc1
--- /dev/null
+++ 
b/changelog/unreleased/SOLR-18244-parallel-http-shard-handler-lost-wakeup-fix.yml
@@ -0,0 +1,10 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+title: Fix several concurrency bugs in HttpShardHandler / 
ParallelHttpShardHandler that
+  could cause search threads to hang in take() or return HTTP 500 instead of 
honoring
+  shards.tolerant under thread-pool saturation
+type: fixed
+authors:
+  - name: Mark Miller
+links:
+  - name: SOLR-18244
+    url: https://issues.apache.org/jira/browse/SOLR-18244
diff --git 
a/solr/core/src/java/org/apache/solr/handler/component/HttpShardHandler.java 
b/solr/core/src/java/org/apache/solr/handler/component/HttpShardHandler.java
index 43165272027..34f9a022d70 100644
--- a/solr/core/src/java/org/apache/solr/handler/component/HttpShardHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/component/HttpShardHandler.java
@@ -96,6 +96,23 @@ public class HttpShardHandler extends ShardHandler {
   protected final BlockingQueue<ShardResponse> responses;
   private final AtomicBoolean canceled = new AtomicBoolean(false);
 
+  // One monitor guards every cancellation-related state transition: the 
canceled flag, the
+  // responseFutureMap, and queueing the responses queue's 
CANCELLATION_NOTIFICATION. Holding it
+  // makes "has everything been canceled / is anything still outstanding?" a 
single atomic question
+  // rather than a set of separately-observed flags. It is a dedicated object 
(not the canceled
+  // flag itself) so the lock's identity does not depend on how cancellation 
state happens to be
+  // stored. Subclasses with extra cancellable bookkeeping must read and 
mutate it under this same
+  // monitor; see ParallelHttpShardHandler.
+  private final Object cancellationLock = new Object();
+
+  protected final Object cancellationLock() {
+    return cancellationLock;
+  }
+
+  protected final boolean isCanceled() {
+    return canceled.get();
+  }
+
   private final Map<String, List<String>> shardToURLs;
   protected LBAsyncSolrClient lbClient;
 
@@ -214,7 +231,7 @@ public class HttpShardHandler extends ShardHandler {
     srsp.setException(exception);
     srsp.setResponseCode(exception.code());
 
-    synchronized (canceled) {
+    synchronized (cancellationLock) {
       if (!canceled.get()) {
         responses.add(srsp);
       }
@@ -266,9 +283,10 @@ public class HttpShardHandler extends ShardHandler {
       ShardResponse srsp,
       long startTimeNS) {
     CompletableFuture<LBSolrClient.Rsp> future = 
this.lbClient.requestAsync(lbReq);
-    // Synchronize on canceled, so that we know precisely whether to add it to 
the responseFutureMap
-    // or not.
-    synchronized (canceled) {
+    // Hold the cancellation lock so the canceled check and the 
responseFutureMap put happen as one
+    // step: either we register this future for later cancellation, or (if 
cancelAll already ran) we
+    // cancel it now and never track it.
+    synchronized (cancellationLock) {
       if (canceled.get() && !future.isDone()) {
         future.cancel(true);
         return;
@@ -280,27 +298,49 @@ public class HttpShardHandler extends ShardHandler {
     // on the map already having the future.
     future.whenComplete(
         (LBSolrClient.Rsp rsp, Throwable throwable) -> {
-          if (rsp != null) {
-            ssr.nl = rsp.getResponse();
-            srsp.setShardAddress(rsp.getServer());
-          } else if (throwable != null) {
-            srsp.setException(throwable);
-            if (throwable instanceof SolrException) {
-              srsp.setResponseCode(((SolrException) throwable).code());
+          try {
+            if (rsp != null) {
+              ssr.nl = rsp.getResponse();
+              srsp.setShardAddress(rsp.getServer());
+            } else if (throwable != null) {
+              srsp.setException(throwable);
+              if (throwable instanceof SolrException) {
+                srsp.setResponseCode(((SolrException) throwable).code());
+              }
             }
-          }
-          ssr.elapsedTime =
-              TimeUnit.MILLISECONDS.convert(System.nanoTime() - startTimeNS, 
TimeUnit.NANOSECONDS);
-          // Synchronize on cancelled so this code and cancelAll() cannot 
happen at the same time
-          synchronized (canceled) {
-            // We don't want to add responses after the requests have been 
canceled
-            if (responseFutureMap.containsKey(srsp)) {
-              responses.add(HttpShardHandler.this.transformResponse(sreq, 
srsp, shard));
+            ssr.elapsedTime =
+                TimeUnit.MILLISECONDS.convert(
+                    System.nanoTime() - startTimeNS, TimeUnit.NANOSECONDS);
+            enqueueIfTracked(srsp, 
HttpShardHandler.this.transformResponse(sreq, srsp, shard));
+          } catch (Exception e) {
+            // If response processing throws (a subclass transformResponse, a 
malformed Rsp, etc.)
+            // the response would never be enqueued — yet responseFutureMap 
still tracks srsp, so a
+            // consumer in take() would park forever. Turn the failure into 
the shard's response and
+            // enqueue it raw, bypassing transformResponse (which may itself 
be the thrower). We
+            // deliberately catch Exception, not Throwable, so a JVM Error 
(e.g. OutOfMemoryError)
+            // propagates instead of being silently downgraded to a shard 
error.
+            srsp.setException(e);
+            if (e instanceof SolrException) {
+              srsp.setResponseCode(((SolrException) e).code());
             }
+            enqueueIfTracked(srsp, srsp);
           }
         });
   }
 
+  /**
+   * Enqueue {@code value} into the {@link #responses} queue iff {@code key} 
is still tracked in
+   * {@link #responseFutureMap}, holding the cancellation monitor so this 
stays atomic with {@link
+   * #cancelAll()}'s clear.
+   */
+  private void enqueueIfTracked(ShardResponse key, ShardResponse value) {
+    synchronized (cancellationLock) {
+      if (responseFutureMap.containsKey(key)) {
+        responses.add(value);
+      }
+    }
+  }
+
   /** Subclasses could modify the request based on the shard */
   @SuppressWarnings("unused")
   protected QueryRequest createQueryRequest(
@@ -330,7 +370,12 @@ public class HttpShardHandler extends ShardHandler {
     ShardResponse previousResponse = null;
     try {
       while (responsesPending()) {
-        ShardResponse rsp = responses.take();
+        ShardResponse rsp = awaitNextResponse();
+        if (rsp == null) {
+          // awaitNextResponse() returned without a response — only happens 
for subclasses that
+          // override with a timed poll. Re-evaluate responsesPending() and 
either re-wait or exit.
+          continue;
+        }
         if (rsp == CANCELLATION_NOTIFICATION) {
           // This is only queued in cancelAll(), so all outstanding futures 
have already been
           // canceled.
@@ -378,6 +423,23 @@ public class HttpShardHandler extends ShardHandler {
     return !responseFutureMap.isEmpty() || !responses.isEmpty();
   }
 
+  /**
+   * Wait for the next response from the {@link #responses} queue. Defaults to 
a blocking {@link
+   * BlockingQueue#take()}.
+   *
+   * <p>Subclasses that gate {@link #responsesPending()} on an async tracker 
outside the {@link
+   * #responses} queue's lifecycle (e.g. {@link 
ParallelHttpShardHandler#submitFutures}) MUST
+   * override this with a timed poll. The cancellation lock can serialize 
{@link
+   * #responsesPending()} reads with state mutations, but it cannot signal the 
queue's internal
+   * {@code Condition}: if the tracker drains without anything being enqueued 
to {@link #responses},
+   * a thread parked in {@link BlockingQueue#take()} would never wake up. 
Returning {@code null}
+   * from this method instructs {@link #take(boolean)} to re-check {@link 
#responsesPending()} and
+   * either re-wait or exit cleanly.
+   */
+  protected ShardResponse awaitNextResponse() throws InterruptedException {
+    return responses.take();
+  }
+
   @Override
   public void cancelAll() {
     // Canceled must be set to true before calling the cancellation code, to 
ensure that new tasks
@@ -388,7 +450,7 @@ public class HttpShardHandler extends ShardHandler {
     // responses will not be recorded.
     // Queue a fake response to notify take() that it should no longer wait on 
responses as the
     // outstanding requests have been canceled
-    synchronized (canceled) {
+    synchronized (cancellationLock) {
       boolean alreadyCanceled = canceled.getAndSet(true);
       if (!alreadyCanceled) {
         // We don't want to queue this multiple times if we are already 
canceled
diff --git 
a/solr/core/src/java/org/apache/solr/handler/component/ParallelHttpShardHandler.java
 
b/solr/core/src/java/org/apache/solr/handler/component/ParallelHttpShardHandler.java
index 5eb9c992ed9..705f19d13bf 100644
--- 
a/solr/core/src/java/org/apache/solr/handler/component/ParallelHttpShardHandler.java
+++ 
b/solr/core/src/java/org/apache/solr/handler/component/ParallelHttpShardHandler.java
@@ -16,19 +16,20 @@
  */
 package org.apache.solr.handler.component;
 
-import java.lang.invoke.MethodHandles;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CompletionException;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import net.jcip.annotations.NotThreadSafe;
 import org.apache.solr.client.solrj.impl.LBSolrClient;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.ModifiableSolrParams;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /**
  * A version of {@link HttpShardHandler} optimized for massively-sharded 
collections.
@@ -39,23 +40,62 @@ import org.slf4j.LoggerFactory;
  *
  * <p>The additional focus on parallelization makes this an ideal 
implementation for collections
  * with many shards.
+ *
+ * <h2>Concurrency model</h2>
+ *
+ * <p>Each shard submit runs as two chained async stages. This class uses the 
words <b>outer</b> and
+ * <b>inner</b> throughout to keep them straight:
+ *
+ * <ul>
+ *   <li><b>outer future</b> — the {@link CompletableFuture#runAsync} we 
schedule on {@link
+ *       #commExecutor}. All it does is call {@code super.makeShardRequest}, 
which kicks off the
+ *       actual HTTP request. Doing this on commExecutor is the whole point of 
the class: {@code
+ *       submit} returns immediately instead of blocking the caller once per 
shard.
+ *   <li><b>inner future</b> — the HTTP future that {@code 
super.makeShardRequest} obtains from
+ *       {@code lbClient.requestAsync}. It completes later, on a Jetty IO 
thread, and its callback
+ *       enqueues the finished {@link ShardResponse} onto {@link #responses}.
+ * </ul>
+ *
+ * <p>Meanwhile the consumer thread sits in {@link #take(boolean)}, looping 
while {@link
+ * #responsesPending()} is true and draining finished responses off {@link 
#responses}.
+ *
+ * <p><b>The core invariant:</b> {@code responsesPending()} must stay true as 
long as any response
+ * might still arrive. If it ever reports false too early, the consumer stops 
waiting and a response
+ * that lands a moment later is lost — and the consumer can park forever. To 
answer "could anything
+ * still arrive?" it has to consult three pieces of state at once:
+ *
+ * <ul>
+ *   <li>{@link #inFlightSubmits} — outer futures not yet finished (this 
class).
+ *   <li>{@code responseFutureMap} — inner futures not yet finished (base 
class).
+ *   <li>{@link #responses} — finished responses not yet consumed (base class).
+ * </ul>
+ *
+ * <p>The risky instant is the hand-off between the two stages: an outer 
future finishes (so {@link
+ * #inFlightSubmits} drops) a hair before its inner future's callback puts the 
response on the
+ * queue. A consumer that samples the three trackers in that gap, 
unsynchronized, can see all three
+ * empty even though a response is moments away. Every {@code synchronized} 
block in this class
+ * exists to close that gap: reads and mutations of all three trackers share 
the one monitor
+ * returned by {@link #cancellationLock()}, so the consumer can never observe 
a torn, in-between
+ * view.
  */
 @NotThreadSafe
 public class ParallelHttpShardHandler extends HttpShardHandler {
 
-  @SuppressWarnings("unused")
-  private static final Logger log = 
LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
-
   private final ExecutorService commExecutor;
 
   /*
-   * Unlike the basic HttpShardHandler, this class allows us to exit submit 
before
-   * the responseFutureMap is updated. If the runnables that
-   * do that are slow to execute the calling code could attempt to 
takeCompleted(),
-   * while pending is still zero. In this condition, the code would assume 
that all
-   * requests are processed (despite the runnables created by this class still
-   * waiting). Thus, we need to track that there are attempts still in flight.
+   * Number of outer futures (see class javadoc) not yet finished. This is the 
authoritative
+   * "is a submit still in flight?" signal that responsesPending() consults.
+   *
+   * Why a counter and not submitFutures.isEmpty(): 
ConcurrentHashMap.size()/isEmpty() are
+   * documented as estimates. Under concurrent put/remove the internal counter 
cells can settle at
+   * a non-zero sum while the table is physically empty, so isEmpty() can 
return false for a
+   * logically empty map — which would leave responsesPending() stuck at true 
and park the consumer
+   * forever. AtomicInteger.get() is exact under any concurrency.
    */
+  private final AtomicInteger inFlightSubmits = new AtomicInteger();
+
+  /* The outer futures themselves, kept only so cancelAll() has something to 
iterate and cancel. */
   private final ConcurrentMap<ShardResponse, CompletableFuture<Void>> 
submitFutures;
 
   public ParallelHttpShardHandler(ParallelHttpShardHandlerFactory 
httpShardHandlerFactory) {
@@ -66,11 +106,39 @@ public class ParallelHttpShardHandler extends 
HttpShardHandler {
 
   @Override
   protected boolean responsesPending() {
-    // ensure we can't exit while loop in HttpShardHandler.take(boolean) until 
we've completed
-    // submitting all of the shard requests
-    return super.responsesPending() || !submitFutures.isEmpty();
+    // Read all three trackers under the shared monitor so the consumer sees a 
consistent view (see
+    // "core invariant" in the class javadoc). Without the lock it could catch 
the brief gap where
+    // an outer future has finished (inFlightSubmits == 0) but its inner 
future's callback has not
+    // yet added the response to the queue, conclude nothing is pending, and 
lose that response.
+    synchronized (cancellationLock()) {
+      return super.responsesPending() || inFlightSubmits.get() > 0;
+    }
   }
 
+  /**
+   * Wait for the next response by polling instead of blocking.
+   *
+   * <p>The lock fixes torn reads, but it cannot wake a thread blocked in 
{@code responses.take()}:
+   * when the last outer future finishes without the inner callback enqueuing 
anything (e.g. the
+   * trackers simply drain to empty), nothing is ever added to the queue to 
signal its internal
+   * condition, so {@code take()} would block forever. Polling instead lets 
{@link #take(boolean)}
+   * periodically re-check {@link #responsesPending()} and exit cleanly — or 
pick up the inner
+   * callback's response once it lands.
+   */
+  @Override
+  protected ShardResponse awaitNextResponse() throws InterruptedException {
+    return responses.poll(50, TimeUnit.MILLISECONDS);
+  }
+
+  /**
+   * Schedules {@code super.makeShardRequest} onto {@link #commExecutor} as an 
outer future (see
+   * class javadoc) instead of running it on the caller's thread.
+   *
+   * <p>The {@link #inFlightSubmits} counter is the thread-safety backbone 
here. It is bumped up
+   * before the outer future is created and back down exactly once when that 
future settles, so it
+   * always reflects the true number of submits still in flight — every exit 
path below maintains
+   * that pairing.
+   */
   @Override
   protected void makeShardRequest(
       ShardRequest sreq,
@@ -80,11 +148,65 @@ public class ParallelHttpShardHandler extends 
HttpShardHandler {
       SimpleSolrResponse ssr,
       ShardResponse srsp,
       long startTimeNS) {
-    CompletableFuture<Void> completableFuture =
-        CompletableFuture.runAsync(
-            () -> super.makeShardRequest(sreq, shard, params, lbReq, ssr, 
srsp, startTimeNS),
-            commExecutor);
-    submitFutures.put(srsp, completableFuture);
+    // The runnable below needs a reference to its own outer future to check 
isCancelled(), but that
+    // future doesn't exist until runAsync returns. We can't close over a 
not-yet-assigned local, so
+    // we hand the runnable an AtomicReference and fill it in once runAsync 
returns the future. That
+    // AtomicReference also gives the runnable's thread a safe, visible read 
of the late assignment.
+    AtomicReference<CompletableFuture<Void>> selfRef = new AtomicReference<>();
+    CompletableFuture<Void> completableFuture;
+    // Count this submit as in flight before it can possibly start, so 
responsesPending() never sees
+    // a zero count while a submit exists. Each branch below pairs this with 
exactly one decrement.
+    inFlightSubmits.incrementAndGet();
+    try {
+      completableFuture =
+          CompletableFuture.runAsync(
+              () -> {
+                // If cancelAll already cancelled this specific outer future 
before it got to run,
+                // skip the HTTP call that super would otherwise start and 
immediately cancel.
+                // selfRef can still be null if this runnable beats the 
selfRef.set() below; that's
+                // harmless, since super.makeShardRequest re-checks the 
canceled flag itself.
+                CompletableFuture<Void> self = selfRef.get();
+                if (self != null && self.isCancelled()) {
+                  return;
+                }
+                super.makeShardRequest(sreq, shard, params, lbReq, ssr, srsp, 
startTimeNS);
+              },
+              commExecutor);
+    } catch (RejectedExecutionException ree) {
+      // commExecutor is saturated or shutting down. If we let this propagate 
it would blow up
+      // SearchHandler's distributed loop before cancelAll() runs, stranding 
shard requests already
+      // submitted and turning a transient overload into an HTTP 500 even 
under shards.tolerant.
+      // Instead record it as an ordinary shard failure (503, i.e. transient) 
so the responses queue
+      // stays consistent. No outer future was created, so there is no 
whenComplete to balance the
+      // increment — undo it here. (If the rejection happens because we are 
already canceling,
+      // recordShardSubmitError intentionally drops the response: the consumer 
is tearing down and
+      // is woken by cancelAll's CANCELLATION_NOTIFICATION, not by this shard.)
+      inFlightSubmits.decrementAndGet();
+      recordShardSubmitError(
+          srsp,
+          new SolrException(
+              SolrException.ErrorCode.SERVICE_UNAVAILABLE,
+              "Comm executor thread pool is full, unable to send request to 
shard: " + shard,
+              ree));
+      return;
+    }
+    // Publish the future before the cancellation check below: if that block 
cancels it, the
+    // runnable is then guaranteed to observe the cancellation through 
selfRef.get().isCancelled().
+    selfRef.set(completableFuture);
+
+    // Register the outer future for cancelAll to find — but only if we 
haven't already been
+    // canceled. This mirrors super.makeShardRequest's check-and-put on 
responseFutureMap and runs
+    // under the same monitor, so registration and cancellation can't 
interleave: if cancelAll has
+    // already run, we cancel this future and skip tracking it (the runnable 
will short-circuit).
+    synchronized (cancellationLock()) {
+      if (isCanceled()) {
+        completableFuture.cancel(true);
+        // Early return: no whenComplete will be attached, so balance the 
increment here.
+        inFlightSubmits.decrementAndGet();
+        return;
+      }
+      submitFutures.put(srsp, completableFuture);
+    }
     completableFuture.whenComplete(
         (r, t) -> {
           try {
@@ -104,23 +226,35 @@ public class ParallelHttpShardHandler extends 
HttpShardHandler {
               }
             }
           } finally {
-            // Remove so that we keep track of in-flight submits only
-            submitFutures.remove(srsp);
+            // Remove from submitFutures first (under the shared monitor, so 
cancelAll always sees a
+            // consistent set), then drop the count. Decrementing last 
guarantees responsesPending()
+            // — which reads both under that monitor — never sees 
inFlightSubmits == 0 while this
+            // entry is still in submitFutures.
+            synchronized (cancellationLock()) {
+              submitFutures.remove(srsp);
+            }
+            inFlightSubmits.decrementAndGet();
           }
         });
   }
 
   @Override
   public void cancelAll() {
-    super.cancelAll();
-    submitFutures
-        .values()
-        .forEach(
-            future -> {
-              if (!future.isDone()) {
-                future.cancel(true);
-              }
-            });
-    submitFutures.clear();
+    // Cancel base-class state and our outer futures together under the one 
monitor, so a concurrent
+    // makeShardRequest can't slip a new outer future into submitFutures while 
we are sweeping it.
+    // Same monitor as super.cancelAll(), so the canceled flag, 
responseFutureMap, and submitFutures
+    // all flip to the canceled state atomically.
+    synchronized (cancellationLock()) {
+      super.cancelAll();
+      submitFutures
+          .values()
+          .forEach(
+              future -> {
+                if (!future.isDone()) {
+                  future.cancel(true);
+                }
+              });
+      submitFutures.clear();
+    }
   }
 }
diff --git 
a/solr/core/src/test/org/apache/solr/handler/component/ParallelHttpShardHandlerTest.java
 
b/solr/core/src/test/org/apache/solr/handler/component/ParallelHttpShardHandlerTest.java
index 4eb0db8858f..454f1a8f9fa 100644
--- 
a/solr/core/src/test/org/apache/solr/handler/component/ParallelHttpShardHandlerTest.java
+++ 
b/solr/core/src/test/org/apache/solr/handler/component/ParallelHttpShardHandlerTest.java
@@ -18,16 +18,35 @@ package org.apache.solr.handler.component;
 
 import java.util.List;
 import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.client.solrj.impl.LBAsyncSolrClient;
 import org.apache.solr.client.solrj.impl.LBSolrClient;
 import org.apache.solr.client.solrj.request.QueryRequest;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.util.ExecutorUtil;
+import org.apache.solr.common.util.SolrNamedThreadFactory;
+import org.junit.BeforeClass;
 import org.junit.Test;
+import org.mockito.Mockito;
 
 public class ParallelHttpShardHandlerTest extends SolrTestCaseJ4 {
 
+  @BeforeClass
+  public static void ensureWorkingMockito() {
+    assumeWorkingMockito();
+  }
+
   private static class DirectExecutorService extends AbstractExecutorService {
     private volatile boolean shutdown;
 
@@ -72,9 +91,7 @@ public class ParallelHttpShardHandlerTest extends 
SolrTestCaseJ4 {
     // Force super.makeShardRequest to throw before it enqueues the response 
future.
     handler.lbClient = null;
 
-    ShardRequest shardRequest = new ShardRequest();
-    shardRequest.params = new ModifiableSolrParams();
-    shardRequest.actualShards = new String[] {"shardA"};
+    ShardRequest shardRequest = buildShardRequest("shardA");
 
     ShardResponse shardResponse = new ShardResponse();
     shardResponse.setShardRequest(shardRequest);
@@ -105,4 +122,288 @@ public class ParallelHttpShardHandlerTest extends 
SolrTestCaseJ4 {
         recorded.getException());
     assertTrue(recorded.getException() instanceof SolrException);
   }
+
+  /**
+   * Verifies the contract that when the commExecutor rejects the runnable, 
the failure is recorded
+   * via recordShardSubmitError (i.e., shows up in the responses queue) rather 
than being propagated
+   * synchronously to the caller.
+   *
+   * <p>This exercises issue #1 from the ParallelHttpShardHandler review: with 
a single-thread
+   * ThreadPoolExecutor backed by a SynchronousQueue, once the worker is busy, 
the next
+   * CompletableFuture.runAsync(...) call throws RejectedExecutionException 
synchronously out of
+   * makeShardRequest. The expected (post-fix) behavior is that the error is 
routed through
+   * recordShardSubmitError instead.
+   */
+  @Test
+  public void testRejectedExecutorRecordsErrorInsteadOfThrowing() throws 
Exception {
+    CountDownLatch holdWorker = new CountDownLatch(1);
+    CountDownLatch workerStarted = new CountDownLatch(1);
+    ThreadPoolExecutor busyExecutor =
+        new ExecutorUtil.MDCAwareThreadPoolExecutor(
+            1, 1, 0L, TimeUnit.MILLISECONDS, new SynchronousQueue<>()); // 
default AbortPolicy
+    try {
+      // Occupy the single worker thread so the next submission has nowhere to 
go.
+      busyExecutor.execute(
+          () -> {
+            workerStarted.countDown();
+            try {
+              holdWorker.await();
+            } catch (InterruptedException e) {
+              Thread.currentThread().interrupt();
+            }
+          });
+      assertTrue("worker did not start within timeout", workerStarted.await(5, 
TimeUnit.SECONDS));
+
+      ParallelHttpShardHandlerFactory factory = new 
ParallelHttpShardHandlerFactory();
+      factory.commExecutor = busyExecutor;
+      ParallelHttpShardHandler handler = new ParallelHttpShardHandler(factory);
+
+      ShardRequest shardRequest = buildShardRequest("shardA");
+
+      ShardResponse shardResponse = new ShardResponse();
+      shardResponse.setShardRequest(shardRequest);
+      shardResponse.setShard("shardA");
+
+      HttpShardHandler.SimpleSolrResponse simpleResponse =
+          new HttpShardHandler.SimpleSolrResponse();
+      shardResponse.setSolrResponse(simpleResponse);
+
+      ModifiableSolrParams params = new ModifiableSolrParams();
+      QueryRequest queryRequest = new QueryRequest(params);
+      LBSolrClient.Endpoint endpoint = new 
LBSolrClient.Endpoint("http://ignored:8983/solr";);
+      LBSolrClient.Req lbReq = new LBSolrClient.Req(queryRequest, 
List.of(endpoint));
+
+      // The desired contract: rejection is captured and surfaced through the 
responses queue
+      // (i.e., this call should not throw RejectedExecutionException).
+      try {
+        handler.makeShardRequest(
+            shardRequest,
+            "shardA",
+            params,
+            lbReq,
+            simpleResponse,
+            shardResponse,
+            System.nanoTime());
+      } catch (RejectedExecutionException ree) {
+        fail(
+            "makeShardRequest should not propagate RejectedExecutionException; 
the failure "
+                + "should be recorded via recordShardSubmitError. Got: "
+                + ree);
+      }
+
+      ShardResponse recorded = handler.responses.poll(2, TimeUnit.SECONDS);
+      assertNotNull(
+          "Expected the executor rejection to be recorded as a shard failure 
in the responses"
+              + " queue, but no response arrived",
+          recorded);
+      assertSame(
+          "The recorded shard response should be the same instance passed in",
+          shardResponse,
+          recorded);
+      assertNotNull(
+          "Expected an exception to be attached to the recorded shard 
response",
+          recorded.getException());
+    } finally {
+      holdWorker.countDown();
+      busyExecutor.shutdownNow();
+      busyExecutor.awaitTermination(5, TimeUnit.SECONDS);
+    }
+  }
+
+  private ShardRequest buildShardRequest(String shard) {
+    ShardRequest sreq = new ShardRequest();
+    sreq.params = new ModifiableSolrParams();
+    sreq.actualShards = new String[] {shard};
+    return sreq;
+  }
+
+  /**
+   * Runs handler.takeCompletedIncludingErrors() on a worker thread with a 
timeout. If take() does
+   * not return within timeoutMs, fails the test with a clear message naming 
the iteration and phase
+   * — this is the signal for the lost-wakeup bug.
+   */
+  private ShardResponse runTakeWithTimeout(
+      ParallelHttpShardHandler handler,
+      ExecutorService takeExecutor,
+      int iteration,
+      String phaseLabel,
+      long timeoutMs)
+      throws Exception {
+    Future<ShardResponse> future = 
takeExecutor.submit(handler::takeCompletedIncludingErrors);
+    try {
+      return future.get(timeoutMs, TimeUnit.MILLISECONDS);
+    } catch (TimeoutException te) {
+      future.cancel(true);
+      throw new AssertionError(
+          "take() hung in iteration "
+              + iteration
+              + " "
+              + phaseLabel
+              + ": did not return within "
+              + timeoutMs
+              + "ms. The worker thread is parked in LinkedBlockingQueue.take() 
waiting for"
+              + " an element that will never arrive because the handler's 
state transitioned"
+              + " to empty without anything being enqueued on the responses 
queue.");
+    } catch (ExecutionException ee) {
+      throw new AssertionError(
+          "take() threw unexpectedly in iteration " + iteration + " " + 
phaseLabel, ee.getCause());
+    }
+  }
+
+  /**
+   * More aggressive variant of the lost-wakeup stress test that uses 
asynchronous inner-future
+   * completion on a dedicated scheduler. In production the inner future (from 
{@code
+   * lbClient.requestAsync}) completes on a Jetty IO thread, not synchronously 
at the registration
+   * site. That timing gap between {@code super.makeShardRequest} returning 
(and the outer {@code
+   * whenComplete} firing to remove {@code submitFutures}) and the inner 
{@code whenComplete} firing
+   * (to add to {@code responses}) is exactly where the observed 930-handler 
hang lives. This test
+   * matches that timing.
+   */
+  @Test
+  public void testTakeDoesNotHangUnderAsyncInnerFutureCompletion() throws 
Exception {
+    // The race window is wide, so a modest count reliably catches a 
regression on routine CI; run
+    // many more under -Dtests.nightly to keep deep coverage without bounding 
every build by the
+    // worst-case (iterations * perIterationTimeoutMs) hang time.
+    final int iterations = TEST_NIGHTLY ? 1000 : 100;
+    final long perIterationTimeoutMs = 3_000;
+
+    ExecutorService commExecutor =
+        new ExecutorUtil.MDCAwareThreadPoolExecutor(
+            0,
+            Integer.MAX_VALUE,
+            5L,
+            TimeUnit.SECONDS,
+            new SynchronousQueue<>(),
+            new SolrNamedThreadFactory("testCommExecutor"));
+
+    // Simulates Jetty IO threads: a small pool that completes the inner 
future asynchronously
+    // some tiny amount of time after requestAsync() returns, exposing the 
race window.
+    ExecutorService mockIoThreads =
+        ExecutorUtil.newMDCAwareFixedThreadPool(2, new 
SolrNamedThreadFactory("testMockIo"));
+
+    ExecutorService takeExecutor =
+        ExecutorUtil.newMDCAwareCachedThreadPool(new 
SolrNamedThreadFactory("testTakeRunner"));
+
+    try {
+      for (int i = 0; i < iterations; i++) {
+        runAsyncRaceCycle(commExecutor, mockIoThreads, takeExecutor, i, 
perIterationTimeoutMs);
+      }
+    } finally {
+      takeExecutor.shutdownNow();
+      takeExecutor.awaitTermination(5, TimeUnit.SECONDS);
+      mockIoThreads.shutdownNow();
+      mockIoThreads.awaitTermination(5, TimeUnit.SECONDS);
+      commExecutor.shutdown();
+      if (!commExecutor.awaitTermination(15, TimeUnit.SECONDS)) {
+        commExecutor.shutdownNow();
+        commExecutor.awaitTermination(5, TimeUnit.SECONDS);
+      }
+    }
+  }
+
+  private void runAsyncRaceCycle(
+      ExecutorService commExecutor,
+      ExecutorService mockIoThreads,
+      ExecutorService takeExecutor,
+      int iteration,
+      long timeoutMs)
+      throws Exception {
+
+    ParallelHttpShardHandlerFactory factory = new 
ParallelHttpShardHandlerFactory();
+    factory.commExecutor = commExecutor;
+    ParallelHttpShardHandler handler = new ParallelHttpShardHandler(factory);
+
+    // LB client that returns a future which completes asynchronously on a 
separate thread —
+    // mimicking the Jetty IO thread model. This creates a real race between:
+    //   (a) the outer runAsync future completing + its whenComplete removing 
submitFutures,
+    //   (b) the inner future completing + its whenComplete adding to 
responses.
+    LBAsyncSolrClient mockLb = Mockito.mock(LBAsyncSolrClient.class);
+    Mockito.when(mockLb.requestAsync(Mockito.any(LBSolrClient.Req.class)))
+        .thenAnswer(
+            inv -> {
+              CompletableFuture<LBSolrClient.Rsp> f = new 
CompletableFuture<>();
+              mockIoThreads.execute(() -> f.complete(new LBSolrClient.Rsp()));
+              return f;
+            });
+    handler.lbClient = mockLb;
+
+    // Single-shard submit → take. This is the simplest real workload. Under 
async inner-future
+    // completion, the outer whenComplete (removing submitFutures) and inner 
whenComplete
+    // (adding to responses) race. If there's a window where 
responsesPending() transitions to
+    // false without the responses queue getting an entry, take() parks 
forever.
+    ShardRequest sreq = buildShardRequest("shard-" + iteration);
+    handler.submit(sreq, "shard-" + iteration, sreq.params);
+
+    ShardResponse rsp =
+        runTakeWithTimeout(handler, takeExecutor, iteration, "async-race", 
timeoutMs);
+
+    assertNotNull(
+        "async-race iteration " + iteration + " take() returned null — 
response was never enqueued",
+        rsp);
+  }
+
+  /**
+   * Invariant test for the cancellation synchronization contract in {@link
+   * ParallelHttpShardHandler}: when {@code makeShardRequest} is invoked while 
{@code canceled} is
+   * already {@code true}, the outer future must be cancelled and NOT tracked 
in {@code
+   * submitFutures}. This keeps {@code submitFutures} consistent with the 
cancellation state —
+   * mirroring {@link HttpShardHandler#makeShardRequest}'s check-and-put 
pattern on {@code
+   * responseFutureMap}.
+   *
+   * <p>Without this invariant, a runnable could observe {@code canceled=true} 
(and early-return in
+   * super) while {@code submitFutures} still tracks its outer future, leaving 
the outer
+   * whenComplete's bookkeeping racing against {@code cancelAll}'s own 
submitFutures sweep.
+   */
+  @Test
+  public void testCanceledMakeShardRequestDoesNotTrackSubmitFutures() throws 
Exception {
+    ExecutorService commExecutor =
+        new ExecutorUtil.MDCAwareThreadPoolExecutor(
+            0,
+            Integer.MAX_VALUE,
+            5L,
+            TimeUnit.SECONDS,
+            new SynchronousQueue<>(),
+            new SolrNamedThreadFactory("invariantTestComm"));
+
+    try {
+      ParallelHttpShardHandlerFactory factory = new 
ParallelHttpShardHandlerFactory();
+      factory.commExecutor = commExecutor;
+      ParallelHttpShardHandler handler = new ParallelHttpShardHandler(factory);
+
+      LBAsyncSolrClient mockLb = Mockito.mock(LBAsyncSolrClient.class);
+      Mockito.when(mockLb.requestAsync(Mockito.any(LBSolrClient.Req.class)))
+          .thenAnswer(inv -> new CompletableFuture<LBSolrClient.Rsp>());
+      handler.lbClient = mockLb;
+
+      // Force canceled=true and drain the CANCELLATION_NOTIFICATION so we can 
observe the
+      // post-cancel state cleanly.
+      handler.cancelAll();
+      assertNotNull(
+          "CANCELLATION_NOTIFICATION should be queued by cancelAll",
+          handler.responses.poll(2, TimeUnit.SECONDS));
+
+      ShardRequest sreq = buildShardRequest("shardA");
+      ShardResponse srsp = new ShardResponse();
+      srsp.setShardRequest(sreq);
+      srsp.setShard("shardA");
+      HttpShardHandler.SimpleSolrResponse ssr = new 
HttpShardHandler.SimpleSolrResponse();
+      srsp.setSolrResponse(ssr);
+
+      ModifiableSolrParams params = new ModifiableSolrParams();
+      QueryRequest queryRequest = new QueryRequest(params);
+      LBSolrClient.Endpoint endpoint = new 
LBSolrClient.Endpoint("http://ignored:8983/solr";);
+      LBSolrClient.Req lbReq = new LBSolrClient.Req(queryRequest, 
List.of(endpoint));
+
+      // Invoke makeShardRequest while canceled=true. Expected: outer is 
cancelled, nothing is
+      // tracked in submitFutures, responsesPending() stays false.
+      handler.makeShardRequest(sreq, "shardA", params, lbReq, ssr, srsp, 
System.nanoTime());
+
+      assertFalse(
+          "submitFutures must not track requests submitted while 
canceled=true",
+          handler.responsesPending());
+    } finally {
+      commExecutor.shutdownNow();
+      commExecutor.awaitTermination(5, TimeUnit.SECONDS);
+    }
+  }
 }


Reply via email to