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

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


The following commit(s) were added to refs/heads/master by this push:
     new 4fa3bac0093 [BUGFIX] Fix interrupted scaling-pool submissions (#19222)
4fa3bac0093 is described below

commit 4fa3bac0093b6940a8099c34c83862e8c0c88e34
Author: Xiang Fu <[email protected]>
AuthorDate: Tue Aug 11 21:20:23 2026 -0700

    [BUGFIX] Fix interrupted scaling-pool submissions (#19222)
---
 .../common/utils/ScalingThreadPoolExecutor.java    | 27 ++++++--
 .../utils/ScalingThreadPoolExecutorTest.java       | 50 +++++++++++++++
 .../invertedindex/LuceneMutableTextIndexTest.java  | 73 +++++++++++++++++++---
 3 files changed, 137 insertions(+), 13 deletions(-)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/ScalingThreadPoolExecutor.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/ScalingThreadPoolExecutor.java
index 1f38af2888f..09457e9473b 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/utils/ScalingThreadPoolExecutor.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/utils/ScalingThreadPoolExecutor.java
@@ -72,19 +72,27 @@ public class ScalingThreadPoolExecutor extends 
ThreadPoolExecutor {
   public static ExecutorService newScalingThreadPool(int min, int max, long 
keepAliveTime) {
     ScalingQueue<Runnable> queue = new ScalingQueue<>();
     ThreadPoolExecutor executor = new ScalingThreadPoolExecutor(min, max, 
keepAliveTime, TimeUnit.MILLISECONDS, queue);
-    executor.setRejectedExecutionHandler(new ForceQueuePolicy());
+    executor.setRejectedExecutionHandler(new ForceQueuePolicy(queue));
     return executor;
   }
 
   /// Used to handle queue rejections. The policy ensures we still queue the 
Runnable, and the rejection ensures the
   /// pool will be expanded if necessary
   static class ForceQueuePolicy implements RejectedExecutionHandler {
+    private final ScalingQueue<Runnable> _queue;
+
+    ForceQueuePolicy(ScalingQueue<Runnable> queue) {
+      _queue = queue;
+    }
+
+    @Override
     public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
-      try {
-        executor.getQueue().put(r);
-      } catch (InterruptedException e) {
-        // should never happen since we never wait
-        throw new RejectedExecutionException(e);
+      if (executor.isShutdown() || !_queue.forceOffer(r)) {
+        throw new RejectedExecutionException("Task " + r + " rejected from " + 
executor);
+      }
+      // Match ThreadPoolExecutor's queueing recheck: do not leave a task 
stranded if shutdown raced the offer.
+      if (executor.isShutdown() && executor.remove(r)) {
+        throw new RejectedExecutionException("Task " + r + " rejected from " + 
executor);
       }
     }
   }
@@ -132,5 +140,12 @@ public class ScalingThreadPoolExecutor extends 
ThreadPoolExecutor {
     public boolean offer(E e) {
       return _currentIdleThreadCount.get() > 0 && super.offer(e);
     }
+
+    /// Unconditionally offers an element after the executor has reached its 
maximum pool size.
+    /// LinkedBlockingQueue.offer is non-interruptible, which allows an 
already-interrupted caller
+    /// to queue work while preserving its interrupt status.
+    boolean forceOffer(E e) {
+      return super.offer(e);
+    }
   }
 }
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/ScalingThreadPoolExecutorTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/ScalingThreadPoolExecutorTest.java
index 7f0e892a468..0942ecb17dd 100644
--- 
a/pinot-common/src/test/java/org/apache/pinot/common/utils/ScalingThreadPoolExecutorTest.java
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/utils/ScalingThreadPoolExecutorTest.java
@@ -18,11 +18,16 @@
  */
 package org.apache.pinot.common.utils;
 
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
 import org.apache.pinot.util.TestUtils;
 import org.testng.annotations.Test;
 
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
 import static org.testng.Assert.assertTrue;
 
 
@@ -72,6 +77,51 @@ public class ScalingThreadPoolExecutorTest {
         "Timed out waiting for thread pool to scale up");
   }
 
+  @Test
+  public void testInterruptedSubmissionIsQueued() throws Exception {
+    ThreadPoolExecutor executorService =
+        (ThreadPoolExecutor) ScalingThreadPoolExecutor.newScalingThreadPool(0, 
1, 500);
+    CountDownLatch taskStarted = new CountDownLatch(1);
+    CountDownLatch releaseTask = new CountDownLatch(1);
+    try {
+      Future<?> activeTask = executorService.submit(() -> {
+        taskStarted.countDown();
+        try {
+          releaseTask.await();
+        } catch (InterruptedException e) {
+          Thread.currentThread().interrupt();
+        }
+      });
+      assertTrue(taskStarted.await(10, TimeUnit.SECONDS), "Timed out waiting 
for the active task to start");
+
+      Future<?> queuedTask;
+      try {
+        Thread.currentThread().interrupt();
+        queuedTask = executorService.submit(() -> { });
+        assertTrue(Thread.currentThread().isInterrupted(), "Submission should 
preserve the caller's interrupt status");
+      } finally {
+        Thread.interrupted();
+        releaseTask.countDown();
+      }
+
+      activeTask.get(10, TimeUnit.SECONDS);
+      queuedTask.get(10, TimeUnit.SECONDS);
+    } finally {
+      Thread.interrupted();
+      releaseTask.countDown();
+      executorService.shutdownNow();
+      assertTrue(executorService.awaitTermination(10, TimeUnit.SECONDS), 
"Executor did not terminate");
+    }
+  }
+
+  @Test
+  public void testSubmissionAfterShutdownIsRejected() {
+    ThreadPoolExecutor executorService =
+        (ThreadPoolExecutor) ScalingThreadPoolExecutor.newScalingThreadPool(0, 
1, 500);
+    executorService.shutdown();
+    assertThrows(RejectedExecutionException.class, () -> 
executorService.submit(() -> { }));
+  }
+
   private Runnable getSleepingRunnable() {
     return () -> {
       try {
diff --git 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/invertedindex/LuceneMutableTextIndexTest.java
 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/invertedindex/LuceneMutableTextIndexTest.java
index f6afbf9a37c..8d71f6a21f0 100644
--- 
a/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/invertedindex/LuceneMutableTextIndexTest.java
+++ 
b/pinot-segment-local/src/test/java/org/apache/pinot/segment/local/realtime/impl/invertedindex/LuceneMutableTextIndexTest.java
@@ -21,6 +21,7 @@ package 
org.apache.pinot.segment.local.realtime.impl.invertedindex;
 import java.io.File;
 import java.io.IOException;
 import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -46,6 +47,8 @@ import org.testng.annotations.Test;
 
 import static org.mockito.Mockito.mock;
 import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.expectThrows;
 
 
 public class LuceneMutableTextIndexTest {
@@ -317,18 +320,74 @@ public class LuceneMutableTextIndexTest {
     assertEquals(_realtimeLuceneTextIndex.getDocIds("invalid"), 
ImmutableRoaringBitmap.bitmapOf());
   }
 
-  @Test(expectedExceptions = ExecutionException.class,
-      expectedExceptionsMessageRegExp = ".*TEXT_MATCH query interrupted while 
querying the consuming segment.*")
+  @Test
   public void testQueryCancellationIsSuccessful()
-      throws InterruptedException, ExecutionException {
+      throws Exception {
     configureIndex(null, null, null, null);
+    CountDownLatch searcherTaskStarted = new CountDownLatch(1);
+    CountDownLatch releaseSearcherTask = new CountDownLatch(1);
+    Future<?> searcherTask = SEARCHER_POOL.getExecutorService().submit(() -> {
+      searcherTaskStarted.countDown();
+      awaitUninterruptibly(releaseSearcherTask);
+    });
+
     // Avoid early finalization by not using Executors.newSingleThreadExecutor 
(java <= 20, JDK-8145304)
     ExecutorService baseExecutor = Executors.newFixedThreadPool(1);
     // Wrap with contextAwareExecutorService to propagate QueryThreadContext 
to child threads
     ExecutorService executor = 
QueryThreadContext.contextAwareExecutorService(baseExecutor);
-    Future<MutableRoaringBitmap> res = executor.submit(() -> 
_realtimeLuceneTextIndex.getDocIds("/.*read.*/"));
-    // Shutdown the base executor to trigger interrupt on the worker thread
-    baseExecutor.shutdownNow();
-    res.get();
+    CountDownLatch queryTaskStarted = new CountDownLatch(1);
+    CountDownLatch runQuery = new CountDownLatch(1);
+
+    try {
+      assertTrue(searcherTaskStarted.await(10, TimeUnit.SECONDS), "Timed out 
waiting for searcher task to start");
+      Future<MutableRoaringBitmap> result = executor.submit(() -> {
+        queryTaskStarted.countDown();
+        awaitUninterruptibly(runQuery);
+        return _realtimeLuceneTextIndex.getDocIds("/.*read.*/");
+      });
+      assertTrue(queryTaskStarted.await(10, TimeUnit.SECONDS), "Timed out 
waiting for query task to start");
+
+      // Interrupt the query worker before it submits to the saturated 
searcher pool, reproducing the CI race.
+      baseExecutor.shutdownNow();
+      runQuery.countDown();
+
+      ExecutionException exception =
+          expectThrows(ExecutionException.class, () -> result.get(10, 
TimeUnit.SECONDS));
+      Throwable cause = exception.getCause();
+      assertTrue(cause instanceof RuntimeException, "Expected RuntimeException 
but got: " + cause);
+      assertTrue(cause.getMessage().contains("TEXT_MATCH query interrupted 
while querying the consuming segment"),
+          "Unexpected exception: " + cause);
+    } finally {
+      runQuery.countDown();
+      baseExecutor.shutdownNow();
+      boolean queryExecutorTerminated = baseExecutor.awaitTermination(10, 
TimeUnit.SECONDS);
+
+      // Queue a marker behind the nested Lucene search, then release the 
single searcher thread. Waiting for the
+      // marker guarantees the search completed before tearDownMethod closes 
the index.
+      Future<?> searcherDrain;
+      try {
+        searcherDrain = SEARCHER_POOL.getExecutorService().submit(() -> { });
+      } finally {
+        releaseSearcherTask.countDown();
+      }
+      searcherTask.get(10, TimeUnit.SECONDS);
+      searcherDrain.get(10, TimeUnit.SECONDS);
+      assertTrue(queryExecutorTerminated, "Query executor did not terminate");
+    }
+  }
+
+  private static void awaitUninterruptibly(CountDownLatch latch) {
+    boolean interrupted = false;
+    while (true) {
+      try {
+        latch.await();
+        break;
+      } catch (InterruptedException e) {
+        interrupted = true;
+      }
+    }
+    if (interrupted) {
+      Thread.currentThread().interrupt();
+    }
   }
 }


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

Reply via email to