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

clintropolis 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 c206beeb7f5 fix: FrameProcessorExecutor cancellation fixes (#20460)
c206beeb7f5 is described below

commit c206beeb7f5fa0bb07577e83b6f80aa5b1856c4d
Author: Clint Wylie <[email protected]>
AuthorDate: Mon Oct 12 21:26:30 2026 -0700

    fix: FrameProcessorExecutor cancellation fixes (#20460)
---
 .../frame/processor/FrameProcessorExecutor.java    | 61 ++++++++++++++++++++--
 .../processor/FrameProcessorExecutorTest.java      | 47 ++++++++++++++++-
 2 files changed, 102 insertions(+), 6 deletions(-)

diff --git 
a/processing/src/main/java/org/apache/druid/frame/processor/FrameProcessorExecutor.java
 
b/processing/src/main/java/org/apache/druid/frame/processor/FrameProcessorExecutor.java
index 530232d4e07..c7ad7a25518 100644
--- 
a/processing/src/main/java/org/apache/druid/frame/processor/FrameProcessorExecutor.java
+++ 
b/processing/src/main/java/org/apache/druid/frame/processor/FrameProcessorExecutor.java
@@ -105,6 +105,10 @@ public class FrameProcessorExecutor
    * If "cancellationId" is provided, it must have previously been registered 
with {@link #registerCancellationId}.
    * Then, it can be used with the {@link #cancel(String)} method to cancel 
all processors with that
    * same cancellationId.
+   *
+   * This method always cleans the processor up, whether or not it runs 
(callers must never clean it up themselves).
+   * The processor is not run at all if "cancellationId" has already been 
canceled, in which case the returned future
+   * comes back already canceled. With a null cancellationId the processor 
always runs.
    */
   public <T> ListenableFuture<T> runFully(final FrameProcessor<T> processor, 
@Nullable final String cancellationId)
   {
@@ -113,7 +117,8 @@ public class FrameProcessorExecutor
     final SettableFuture<T> finished = 
registerCancelableFuture(SettableFuture.create(), true, cancellationId);
 
     if (finished.isDone()) {
-      // Possibly due to starting life out being canceled.
+      // Canceled either before or just after registerCancelableFuture ran; 
clean up because nothing else can
+      cancel(Collections.singleton(processor));
       return finished;
     }
 
@@ -451,8 +456,40 @@ public class FrameProcessorExecutor
     );
 
     logProcessorStatusString(processor, finished.isDone(), null, null, null);
-    registerCancelableProcessor(processor, cancellationId);
-    exec.execute(runnable);
+
+    if (!registerCancelableProcessor(processor, cancellationId)) {
+      // cancel(cancellationId) ran after "finished" was registered, so it 
already cleaned cancelableProcessors
+      // without seeing this one; clean up because nothing else can, and 
cancel "finished" because that cancel
+      // resolves return futures last (after waiting on the processors it did 
see)
+      cancel(Collections.singleton(processor));
+      finished.cancel(true);
+      return finished;
+    }
+
+    try {
+      exec.execute(runnable);
+    }
+    catch (Throwable e) {
+      // registered, but never going to run; clean up only if we took the 
processor out of cancelableProcessors
+      // ourselves (otherwise a concurrent cancel is doing the cleanup)
+      final boolean doCleanup;
+
+      if (cancellationId != null) {
+        synchronized (lock) {
+          doCleanup = cancelableProcessors.remove(cancellationId, processor);
+        }
+      } else {
+        doCleanup = true;
+      }
+
+      if (doCleanup) {
+        cancel(Collections.singleton(processor));
+      }
+
+      finished.setException(e);
+      throw e;
+    }
+
     return finished;
   }
 
@@ -488,6 +525,9 @@ public class FrameProcessorExecutor
    * Registers a cancellationId, so it can be provided to {@link #runFully} or 
{@link #runAllFully}. To avoid the
    * set of active cancellationIds growing without bound, callers must also 
call {@link #cancel(String)} on the
    * same cancellationId when done using it.
+   *
+   * A cancellationId must also not be registered again once canceled. Nothing 
here checks for it, and a run still in
+   * flight under the old registration would silently attach itself to the new 
one.
    */
   public void registerCancellationId(final String cancellationId)
   {
@@ -666,13 +706,26 @@ public class FrameProcessorExecutor
     }
   }
 
-  private <T> void registerCancelableProcessor(final FrameProcessor<T> 
processor, @Nullable final String cancellationId)
+  /**
+   * Register a processor so {@link #cancel(String)} can find it.
+   *
+   * @return true if registered, or if there is no cancellationId to register 
under. False if {@code cancellationId}
+   * is already canceled, in which case the caller must not run the processor 
and owns cleaning it up.
+   */
+  private <T> boolean registerCancelableProcessor(
+      final FrameProcessor<T> processor,
+      @Nullable final String cancellationId
+  )
   {
     if (cancellationId != null) {
       synchronized (lock) {
+        if (!activeCancellationIds.contains(cancellationId)) {
+          return false;
+        }
         cancelableProcessors.put(cancellationId, processor);
       }
     }
+    return true;
   }
 
   /**
diff --git 
a/processing/src/test/java/org/apache/druid/frame/processor/FrameProcessorExecutorTest.java
 
b/processing/src/test/java/org/apache/druid/frame/processor/FrameProcessorExecutorTest.java
index b728c2b7469..5c6a25943ef 100644
--- 
a/processing/src/test/java/org/apache/druid/frame/processor/FrameProcessorExecutorTest.java
+++ 
b/processing/src/test/java/org/apache/druid/frame/processor/FrameProcessorExecutorTest.java
@@ -78,7 +78,10 @@ import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CancellationException;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
 import java.util.stream.Collectors;
@@ -391,6 +394,46 @@ public class FrameProcessorExecutorTest
       exec.cancel("nonexistent");
     }
 
+    @Test
+    @Timeout(value = 30_000L, unit = TimeUnit.MILLISECONDS)
+    public void 
test_runFully_cancelRacingRegistration_leavesNoOrphanProcessor() throws 
Exception
+    {
+      final ExecutorService launcher = 
Execs.singleThreaded("cancel-race-launcher-%s");
+
+      try {
+        for (int i = 0; i < 300; i++) {
+          final String cancellationId = "race-" + i;
+          final SleepyFrameProcessor processor = new SleepyFrameProcessor();
+          exec.registerCancellationId(cancellationId);
+
+          final CountDownLatch ready = new CountDownLatch(1);
+          final Future<ListenableFuture<Long>> launched = launcher.submit(() 
-> {
+            ready.countDown();
+            return exec.runFully(processor, cancellationId);
+          });
+
+          // Release the launcher and cancel from this thread, so the cancel 
lands somewhere inside runFully.
+          ready.await();
+          exec.cancel(cancellationId);
+
+          final ListenableFuture<Long> future = launched.get(30, 
TimeUnit.SECONDS);
+
+          // Asserted every iteration rather than once at the end, so a leak 
is reported against the iteration that
+          // caused it
+          Assertions.assertEquals(
+              0,
+              exec.cancelableProcessorCount(),
+              StringUtils.format("orphaned processor on iteration[%d]", i)
+          );
+          Assertions.assertTrue(future.isDone(), StringUtils.format("future 
resolved on iteration[%d]", i));
+          Assertions.assertTrue(processor.didCleanup(), 
StringUtils.format("processor cleaned on iteration[%d]", i));
+        }
+      }
+      finally {
+        launcher.shutdownNow();
+      }
+    }
+
     @Test
     public void test_runFully_nonexistentCancellationId()
     {
@@ -400,11 +443,11 @@ public class FrameProcessorExecutorTest
       // Don't registerCancellationId(cancellationId).
       final ListenableFuture<Long> future = exec.runFully(processor, 
cancellationId);
 
-      // Future should be immediately canceled, without running the processor.
+      // Future should be immediately canceled, without running the processor, 
but still cleaned up
       Assertions.assertTrue(future.isDone());
       Assertions.assertTrue(future.isCancelled());
       Assertions.assertFalse(processor.didGetInterrupt());
-      Assertions.assertFalse(processor.didCleanup());
+      Assertions.assertTrue(processor.didCleanup());
     }
 
     @Test


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

Reply via email to