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

kfaraz 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 8d634fdf8d9 fix: cancel active `/workOrder` futures on query 
cancellation (#20313)
8d634fdf8d9 is described below

commit 8d634fdf8d9ac14b763db30636157407261675fc
Author: Sasha Syrotenko <[email protected]>
AuthorDate: Thu Oct 15 17:13:56 2026 +0300

    fix: cancel active `/workOrder` futures on query cancellation (#20313)
    
    Changes:
    - Track in-flight requests in `DartWorkerClientImpl`
    - Cancel in-flight requests when the client is closed
---
 embedded-tests/pom.xml                             |   6 +
 .../testing/embedded/msq/BaseDartQueryTest.java    | 131 ++++++++++++++
 .../msq/EmbeddedDartQueryCancellationTest.java     | 195 +++++++++++++++++++++
 .../embedded/msq/EmbeddedDartReportApiTest.java    | 103 +----------
 .../msq/dart/controller/DartWorkerManager.java     |   7 +
 .../msq/dart/worker/DartWorkerClientImpl.java      |  78 ++++++++-
 .../org/apache/druid/msq/exec/ControllerImpl.java  |   2 +-
 .../msq/dart/controller/DartWorkerManagerTest.java |  43 +++++
 .../msq/dart/worker/DartWorkerClientImplTest.java  |  99 +++++++++++
 9 files changed, 559 insertions(+), 105 deletions(-)

diff --git a/embedded-tests/pom.xml b/embedded-tests/pom.xml
index 3928027576a..31a89e0b921 100644
--- a/embedded-tests/pom.xml
+++ b/embedded-tests/pom.xml
@@ -401,6 +401,7 @@
       <artifactId>simple-client-sslcontext</artifactId>
       <version>${project.parent.version}</version>
       <scope>test</scope>
+
     </dependency>
     <!-- Pin protobuf-java-util to the version from druid-protobuf-extensions 
to satisfy
          the enforcer's RequireUpperBoundDeps rule (google-cloud-storage 
brings a lower version). -->
@@ -830,6 +831,11 @@
       <artifactId>docker-java-transport</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.eclipse.jetty</groupId>
+      <artifactId>jetty-server</artifactId>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <build>
diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/BaseDartQueryTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/BaseDartQueryTest.java
new file mode 100644
index 00000000000..e3c6560fe79
--- /dev/null
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/BaseDartQueryTest.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.testing.embedded.msq;
+
+import org.apache.druid.common.utils.IdUtils;
+import org.apache.druid.guice.SleepModule;
+import org.apache.druid.indexing.common.task.IndexTask;
+import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.sql.http.GetQueryReportResponse;
+import org.apache.druid.testing.embedded.EmbeddedBroker;
+import org.apache.druid.testing.embedded.EmbeddedClusterApis;
+import org.apache.druid.testing.embedded.EmbeddedCoordinator;
+import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
+import org.apache.druid.testing.embedded.EmbeddedHistorical;
+import org.apache.druid.testing.embedded.EmbeddedIndexer;
+import org.apache.druid.testing.embedded.EmbeddedOverlord;
+import org.apache.druid.testing.embedded.auth.EmbeddedBasicAuthResource;
+import org.apache.druid.testing.embedded.indexing.MoreResources;
+import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase;
+import org.junit.jupiter.api.BeforeAll;
+
+/**
+ * Shared embedded cluster setup for Dart query tests.
+ */
+public abstract class BaseDartQueryTest extends EmbeddedClusterTestBase
+{
+  private static final int MAX_RETAINED_REPORT_COUNT = 10;
+
+  protected final EmbeddedBroker broker1 = new EmbeddedBroker();
+  protected final EmbeddedBroker broker2 = new EmbeddedBroker();
+  protected final EmbeddedIndexer indexer = new EmbeddedIndexer();
+  protected final EmbeddedOverlord overlord = new EmbeddedOverlord();
+  protected final EmbeddedHistorical historical = new EmbeddedHistorical();
+  protected final EmbeddedCoordinator coordinator = new EmbeddedCoordinator();
+
+  protected EmbeddedMSQApis msqApis;
+  protected String ingestedDataSource;
+
+  private void configureBroker(final EmbeddedBroker broker, final int port)
+  {
+    broker.addProperty("druid.msq.dart.controller.heapFraction", "0.5")
+          .addProperty("druid.msq.dart.controller.maxRetainedReportCount", 
String.valueOf(MAX_RETAINED_REPORT_COUNT))
+          .addProperty("druid.query.default.context.maxConcurrentStages", "1")
+          .addProperty("druid.sql.planner.enableSysQueriesTable", "true")
+          .addProperty("druid.plaintextPort", String.valueOf(port));
+  }
+
+  @Override
+  protected EmbeddedDruidCluster createCluster()
+  {
+    coordinator.addProperty("druid.manager.segments.useIncrementalCache", 
"always");
+    overlord.addProperty("druid.manager.segments.pollDuration", "PT0.1s");
+
+    configureBroker(broker1, 7082);
+    configureBroker(broker2, 7083);
+
+    historical.addProperty("druid.msq.dart.worker.heapFraction", "0.5")
+              .addProperty("druid.msq.dart.worker.concurrentQueries", "1");
+
+    indexer.setServerMemory(400_000_000)
+           .addProperty("druid.segment.handoff.pollDuration", "PT0.1s")
+           .addProperty("druid.processing.numThreads", "2")
+           .addProperty("druid.worker.capacity", "4");
+
+    return EmbeddedDruidCluster.withEmbeddedDerbyAndZookeeper()
+                               .addCommonProperty("druid.msq.dart.enabled", 
"true")
+                               .addResource(new EmbeddedBasicAuthResource())
+                               .useLatchableEmitter()
+                               .addServer(coordinator)
+                               .addServer(overlord)
+                               .addServer(broker1)
+                               .addServer(broker2)
+                               .addServer(indexer)
+                               .addServer(historical)
+                               .addExtension(SleepModule.class);
+  }
+
+  @BeforeAll
+  protected void setupData()
+  {
+    msqApis = new EmbeddedMSQApis(cluster, overlord);
+
+    ingestedDataSource = EmbeddedClusterApis.createTestDatasourceName();
+    final String taskId = IdUtils.getRandomId();
+    final IndexTask task = 
MoreResources.Task.BASIC_INDEX.get().dataSource(ingestedDataSource).withId(taskId);
+    cluster.callApi().onLeaderOverlord(o -> o.runTask(taskId, task));
+    cluster.callApi().waitForTaskToSucceed(taskId, overlord);
+
+    cluster.callApi().waitForAllSegmentsToBeAvailable(ingestedDataSource, 
coordinator, broker1);
+    cluster.callApi().waitForAllSegmentsToBeAvailable(ingestedDataSource, 
coordinator, broker2);
+  }
+
+  /**
+   * Polls the report API on {@link #broker1} until a report is available.
+   */
+  protected GetQueryReportResponse waitForReport(final String sqlQueryId)
+  {
+    final long timeout = 30_000;
+    final long deadline = System.currentTimeMillis() + timeout;
+    while (System.currentTimeMillis() < deadline) {
+      final GetQueryReportResponse report = 
msqApis.getDartQueryReport(sqlQueryId, broker1);
+      if (report != null) {
+        return report;
+      }
+      try {
+        Thread.sleep(100);
+      }
+      catch (InterruptedException e) {
+        throw new RuntimeException(e);
+      }
+    }
+    throw new ISE("Timed out after[%,d] ms waiting for query to be in RUNNING 
state", timeout);
+  }
+}
diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedDartQueryCancellationTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedDartQueryCancellationTest.java
new file mode 100644
index 00000000000..d769587f4cb
--- /dev/null
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedDartQueryCancellationTest.java
@@ -0,0 +1,195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.druid.testing.embedded.msq;
+
+import com.google.common.util.concurrent.ListenableFuture;
+import org.apache.druid.java.util.common.StringUtils;
+import org.apache.druid.java.util.common.concurrent.Execs;
+import org.apache.druid.query.QueryContexts;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.NetworkConnector;
+import org.eclipse.jetty.server.Server;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Embedded tests for Dart query cancellation.
+ */
+public class EmbeddedDartQueryCancellationTest extends BaseDartQueryTest
+{
+  @Test
+  @Timeout(value = 180, unit = TimeUnit.SECONDS)
+  public void test_cancelDartQuery_cancelsWorkOrderRetries() throws Exception
+  {
+    final String sqlQueryId = UUID.randomUUID().toString();
+    final String sql = StringUtils.format(
+        "SELECT SLEEP(TIMESTAMP_TO_MILLIS(__time) * 0 + 60) FROM \"%s\"",
+        ingestedDataSource
+    );
+    final CountDownLatch workOrderAttempts = new CountDownLatch(2);
+    final CountDownLatch workOrderAfterCancellation = new CountDownLatch(1);
+    final AtomicBoolean cancellationCompleted = new AtomicBoolean();
+
+    // Drop worker responses so the controller keeps retrying /workOrder.
+    try (HistoricalBlackhole blackhole = new HistoricalBlackhole(
+        false,
+        true,
+        workOrderAttempts,
+        workOrderAfterCancellation,
+        cancellationCompleted
+    )) {
+      final ListenableFuture<String> queryFuture = msqApis.submitDartSqlAsync(
+          sql,
+          Map.of(QueryContexts.CTX_SQL_QUERY_ID, sqlQueryId),
+          broker1
+      );
+      try {
+        waitForReport(sqlQueryId);
+
+        Assertions.assertTrue(
+            workOrderAttempts.await(15, TimeUnit.SECONDS),
+            "Dart controller did not retry /workOrder"
+        );
+
+        // Cancel while the /workOrder request is in flight.
+        final ListenableFuture<Boolean> cancellation = 
cluster.callApi().onTargetBrokerAsync(
+            broker1,
+            broker -> broker.cancelSqlQuery(sqlQueryId)
+        );
+
+        Assertions.assertTrue(cancellation.get(30, TimeUnit.SECONDS));
+        cancellationCompleted.set(true);
+
+        Assertions.assertFalse(
+            workOrderAfterCancellation.await(5, TimeUnit.SECONDS),
+            "Dart controller sent /workOrder after cancellation"
+        );
+      }
+      finally {
+        queryFuture.cancel(true);
+      }
+    }
+  }
+
+  /**
+   * Replaces the historical HTTP connector with a socket that selectively 
acknowledges worker requests.
+   */
+  private class HistoricalBlackhole implements AutoCloseable
+  {
+    private final boolean acknowledgeStop;
+    private final boolean acknowledgeWorkOrder;
+    private final CountDownLatch workOrderAttempts;
+    private final CountDownLatch workOrderAfterCancellation;
+    private final AtomicBoolean cancellationCompleted;
+    private final Connector connector;
+    private final ServerSocket socket;
+    private final ExecutorService executor;
+    private boolean restored;
+
+    private HistoricalBlackhole(
+        final boolean acknowledgeWorkOrder,
+        final boolean acknowledgeStop,
+        final CountDownLatch workOrderAttempts,
+        final CountDownLatch workOrderAfterCancellation,
+        final AtomicBoolean cancellationCompleted
+    ) throws Exception
+    {
+      this.acknowledgeWorkOrder = acknowledgeWorkOrder;
+      this.acknowledgeStop = acknowledgeStop;
+      this.workOrderAttempts = workOrderAttempts;
+      this.workOrderAfterCancellation = workOrderAfterCancellation;
+      this.cancellationCompleted = cancellationCompleted;
+      this.connector = 
historical.bindings().getInstance(Server.class).getConnectors()[0];
+
+      final int port = ((NetworkConnector) connector).getLocalPort();
+      // Keep the Historical announced and replace only its HTTP endpoint.
+      connector.stop();
+
+      this.socket = new ServerSocket(port);
+      this.executor = 
Execs.singleThreaded("EmbeddedDartQueryCancellationTest-blackhole-%s");
+      this.executor.submit(this::acceptRequests);
+    }
+
+    private void acceptRequests()
+    {
+      while (!socket.isClosed()) {
+        try (Socket request = socket.accept()) {
+          request.setSoTimeout(1_000);
+          final String requestLine = new BufferedReader(
+              new InputStreamReader(request.getInputStream(), 
StandardCharsets.UTF_8)
+          ).readLine();
+
+          final boolean workOrder = requestLine != null && 
requestLine.contains("/workOrder");
+          final boolean stop = requestLine != null && 
requestLine.contains("/stop");
+
+          if (workOrder) {
+            if (cancellationCompleted.get()) {
+              workOrderAfterCancellation.countDown();
+            } else {
+              workOrderAttempts.countDown();
+            }
+          }
+
+          if ((workOrder && acknowledgeWorkOrder) || (stop && 
acknowledgeStop)) {
+            final OutputStream output = request.getOutputStream();
+            output.write(
+                "HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: 
close\r\n\r\n"
+                    .getBytes(StandardCharsets.UTF_8)
+            );
+            output.flush();
+          }
+        }
+        catch (final IOException e) {
+          if (!socket.isClosed()) {
+            // The client can close a request while cancellation is racing 
with a retry.
+          }
+        }
+      }
+    }
+
+    @Override
+    public void close() throws Exception
+    {
+      if (!restored) {
+        restored = true;
+        socket.close();
+        executor.shutdownNow();
+        if (!connector.isRunning()) {
+          connector.start();
+        }
+      }
+    }
+  }
+}
diff --git 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedDartReportApiTest.java
 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedDartReportApiTest.java
index 4e933d7d77e..16ff27cb4ca 100644
--- 
a/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedDartReportApiTest.java
+++ 
b/embedded-tests/src/test/java/org/apache/druid/testing/embedded/msq/EmbeddedDartReportApiTest.java
@@ -25,15 +25,11 @@ import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.ListenableFuture;
 import io.netty.handler.codec.http.HttpMethod;
 import io.netty.handler.codec.http.HttpResponseStatus;
-import org.apache.druid.common.utils.IdUtils;
 import org.apache.druid.data.input.impl.CsvInputFormat;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.error.ThrowableMatcher;
-import org.apache.druid.guice.SleepModule;
 import org.apache.druid.indexer.TaskState;
 import org.apache.druid.indexer.report.TaskReport;
-import org.apache.druid.indexing.common.task.IndexTask;
-import org.apache.druid.java.util.common.ISE;
 import org.apache.druid.java.util.common.StringUtils;
 import org.apache.druid.java.util.http.client.CredentialedHttpClient;
 import org.apache.druid.java.util.http.client.HttpClient;
@@ -55,16 +51,8 @@ import org.apache.druid.sql.http.GetQueryReportResponse;
 import org.apache.druid.sql.http.QueryInfo;
 import org.apache.druid.sql.http.ResultFormat;
 import org.apache.druid.testing.embedded.EmbeddedBroker;
-import org.apache.druid.testing.embedded.EmbeddedClusterApis;
-import org.apache.druid.testing.embedded.EmbeddedCoordinator;
-import org.apache.druid.testing.embedded.EmbeddedDruidCluster;
-import org.apache.druid.testing.embedded.EmbeddedHistorical;
-import org.apache.druid.testing.embedded.EmbeddedIndexer;
-import org.apache.druid.testing.embedded.EmbeddedOverlord;
 import org.apache.druid.testing.embedded.auth.EmbeddedBasicAuthResource;
 import org.apache.druid.testing.embedded.auth.HttpUtil;
-import org.apache.druid.testing.embedded.indexing.MoreResources;
-import org.apache.druid.testing.embedded.junit5.EmbeddedClusterTestBase;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
@@ -81,85 +69,20 @@ import java.util.stream.Collectors;
  * Embedded test for the Dart report API at {@code 
/druid/v2/sql/queries/{id}/reports}.
  * Uses batch ingestion to avoid dependency on Kafka/Docker.
  */
-public class EmbeddedDartReportApiTest extends EmbeddedClusterTestBase
+public class EmbeddedDartReportApiTest extends BaseDartQueryTest
 {
-  private static final int MAX_RETAINED_REPORT_COUNT = 10;
-
   // Authentication constants - use shared constants from 
EmbeddedBasicAuthResource where available
   private static final String REGULAR_USER = "regularUser";
   private static final String REGULAR_PASSWORD = "helloworld";
 
-  private final EmbeddedBroker broker1 = new EmbeddedBroker();
-  private final EmbeddedBroker broker2 = new EmbeddedBroker();
-  private final EmbeddedIndexer indexer = new EmbeddedIndexer();
-  private final EmbeddedOverlord overlord = new EmbeddedOverlord();
-  private final EmbeddedHistorical historical = new EmbeddedHistorical();
-  private final EmbeddedCoordinator coordinator = new EmbeddedCoordinator();
-
-  private EmbeddedMSQApis msqApis;
-  private String ingestedDataSource;
   private HttpClient adminClient;
   private HttpClient regularUserClient;
 
-  private void configureBroker(EmbeddedBroker broker, int port)
-  {
-    broker.addProperty("druid.msq.dart.controller.heapFraction", "0.5")
-          .addProperty("druid.msq.dart.controller.maxRetainedReportCount", 
String.valueOf(MAX_RETAINED_REPORT_COUNT))
-          .addProperty("druid.query.default.context.maxConcurrentStages", "1")
-          .addProperty("druid.sql.planner.enableSysQueriesTable", "true")
-          .addProperty("druid.plaintextPort", String.valueOf(port));
-  }
-
-  @Override
-  protected EmbeddedDruidCluster createCluster()
-  {
-    coordinator.addProperty("druid.manager.segments.useIncrementalCache", 
"always");
-    overlord.addProperty("druid.manager.segments.pollDuration", "PT0.1s");
-
-    // Enable Dart with report retention on both brokers, with different ports
-    configureBroker(broker1, 7082);
-    configureBroker(broker2, 7083);
-
-    historical.addProperty("druid.msq.dart.worker.heapFraction", "0.5")
-              .addProperty("druid.msq.dart.worker.concurrentQueries", "1");
-
-    indexer.setServerMemory(400_000_000)
-           .addProperty("druid.segment.handoff.pollDuration", "PT0.1s")
-           .addProperty("druid.processing.numThreads", "2")
-           .addProperty("druid.worker.capacity", "4");
-
-    return EmbeddedDruidCluster.withEmbeddedDerbyAndZookeeper()
-                               .addCommonProperty("druid.msq.dart.enabled", 
"true")
-                               .addResource(new EmbeddedBasicAuthResource())
-                               .useLatchableEmitter()
-                               .addServer(coordinator)
-                               .addServer(overlord)
-                               .addServer(broker1)
-                               .addServer(broker2)
-                               .addServer(indexer)
-                               .addServer(historical)
-                               .addExtension(SleepModule.class);
-  }
-
   @BeforeAll
-  protected void setupData()
+  protected void setupAuth()
   {
-    msqApis = new EmbeddedMSQApis(cluster, overlord);
-
-    // Set up HTTP clients for admin and regular user
     setupAdminClient();
     setupRegularUserAndClient();
-
-    // Ingest test data once, using batch ingestion.
-    ingestedDataSource = EmbeddedClusterApis.createTestDatasourceName();
-    final String taskId = IdUtils.getRandomId();
-    final IndexTask task = 
MoreResources.Task.BASIC_INDEX.get().dataSource(ingestedDataSource).withId(taskId);
-    cluster.callApi().onLeaderOverlord(o -> o.runTask(taskId, task));
-    cluster.callApi().waitForTaskToSucceed(taskId, overlord);
-
-    // Wait for segments to be available on both brokers
-    cluster.callApi().waitForAllSegmentsToBeAvailable(ingestedDataSource, 
coordinator, broker1);
-    cluster.callApi().waitForAllSegmentsToBeAvailable(ingestedDataSource, 
coordinator, broker2);
   }
 
   private void setupAdminClient()
@@ -579,28 +502,6 @@ public class EmbeddedDartReportApiTest extends 
EmbeddedClusterTestBase
     return sqlQueryIds;
   }
 
-  /**
-   * Polls the report API on {@link #broker1} until a report is available.
-   */
-  private GetQueryReportResponse waitForReport(String sqlQueryId)
-  {
-    final long timeout = 30_000;
-    final long deadline = System.currentTimeMillis() + timeout;
-    while (System.currentTimeMillis() < deadline) {
-      final GetQueryReportResponse report = 
msqApis.getDartQueryReport(sqlQueryId, broker1);
-      if (report != null) {
-        return report;
-      }
-      try {
-        Thread.sleep(100);
-      }
-      catch (InterruptedException e) {
-        throw new RuntimeException(e);
-      }
-    }
-    throw new ISE("Timed out after[%,d] ms waiting for query to be in RUNNING 
state", timeout);
-  }
-
   /**
    * Polls the report API on the specified brokers until completed reports are 
available from all of them.
    */
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/dart/controller/DartWorkerManager.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/dart/controller/DartWorkerManager.java
index 5df0f347f59..76962b1974d 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/dart/controller/DartWorkerManager.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/dart/controller/DartWorkerManager.java
@@ -215,6 +215,13 @@ public class DartWorkerManager implements WorkerManager
       }
 
       stopFuture.set(null);
+    } else if (state.get() == State.STOPPED) {
+      try {
+        FutureUtils.getUnchecked(stopFuture, false);
+      }
+      catch (Throwable ignored) {
+        // Suppress errors.
+      }
     }
   }
 }
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/dart/worker/DartWorkerClientImpl.java
 
b/multi-stage-query/src/main/java/org/apache/druid/msq/dart/worker/DartWorkerClientImpl.java
index 804daa42f7f..9357a874483 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/dart/worker/DartWorkerClientImpl.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/dart/worker/DartWorkerClientImpl.java
@@ -21,7 +21,9 @@ package org.apache.druid.msq.dart.worker;
 
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.jaxrs.smile.SmileMediaTypes;
+import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
 import com.google.errorprone.annotations.concurrent.GuardedBy;
 import io.netty.handler.codec.http.HttpMethod;
 import it.unimi.dsi.fastutil.Pair;
@@ -30,6 +32,7 @@ import org.apache.druid.java.util.common.logger.Logger;
 import org.apache.druid.java.util.http.client.response.HttpResponseHandler;
 import org.apache.druid.msq.dart.worker.http.DartWorkerResource;
 import org.apache.druid.msq.exec.WorkerClient;
+import org.apache.druid.msq.kernel.WorkOrder;
 import org.apache.druid.msq.rpc.BaseWorkerClientImpl;
 import org.apache.druid.query.QueryContexts;
 import org.apache.druid.rpc.FixedServiceLocator;
@@ -44,8 +47,12 @@ import org.apache.druid.utils.CloseableUtils;
 import javax.annotation.Nullable;
 import java.io.Closeable;
 import java.net.URI;
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 /**
  * Dart implementation of {@link WorkerClient}. Uses the same {@link 
BaseWorkerClientImpl} as the task-based engine.
@@ -68,6 +75,10 @@ public class DartWorkerClientImpl extends 
BaseWorkerClientImpl implements DartWo
   @GuardedBy("clientMap")
   private boolean closed;
 
+  private final Set<ListenableFuture<?>> activeRequests = 
ConcurrentHashMap.newKeySet();
+  private final Set<ListenableFuture<Void>> activeWorkOrders = 
ConcurrentHashMap.newKeySet();
+  private volatile boolean isStopping = false;
+
   /**
    * Create a worker client.
    *
@@ -127,6 +138,13 @@ public class DartWorkerClientImpl extends 
BaseWorkerClientImpl implements DartWo
   public void close()
   {
     synchronized (clientMap) {
+      closed = true;
+
+      // Cancel requests before closing locators so in-flight requests do not 
continue retrying.
+      final List<ListenableFuture<?>> requests = new 
ArrayList<>(activeRequests);
+      activeRequests.clear();
+      requests.forEach(request -> request.cancel(true));
+
       for (Map.Entry<String, Pair<ServiceClient, Closeable>> entry : 
clientMap.entrySet()) {
         CloseableUtils.closeAndSuppressExceptions(
             entry.getValue().right(),
@@ -135,13 +153,34 @@ public class DartWorkerClientImpl extends 
BaseWorkerClientImpl implements DartWo
       }
 
       clientMap.clear();
-      closed = true;
     }
   }
 
   @Override
-  public ListenableFuture<?> stopWorker(String workerId)
+  public ListenableFuture<Void> postWorkOrder(final String workerId, final 
WorkOrder workOrder)
+  {
+    synchronized (clientMap) {
+      if (isStopping) {
+        return Futures.immediateCancelledFuture();
+      }
+
+      final ListenableFuture<Void> future = super.postWorkOrder(workerId, 
workOrder);
+
+      activeWorkOrders.add(future);
+      future.addListener(() -> activeWorkOrders.remove(future), 
MoreExecutors.directExecutor());
+
+      return future;
+    }
+  }
+
+  @Override
+  public ListenableFuture<?> stopWorker(final String workerId)
   {
+    synchronized (clientMap) {
+      isStopping = true;
+      activeWorkOrders.forEach(future -> future.cancel(true));
+    }
+
     return getClient(workerId).asyncRequest(
         new RequestBuilder(HttpMethod.POST, "/stop"),
         IgnoreHttpResponseHandler.INSTANCE
@@ -165,7 +204,7 @@ public class DartWorkerClientImpl extends 
BaseWorkerClientImpl implements DartWo
       client = baseClient;
     }
 
-    return Pair.of(client, locator);
+    return Pair.of(new RequestTrackingClient(client, activeRequests), locator);
   }
 
   private Pair<ServiceClient, Closeable> getClientAndLocator(final String 
workerIdString)
@@ -184,6 +223,39 @@ public class DartWorkerClientImpl extends 
BaseWorkerClientImpl implements DartWo
     }
   }
 
+  private static class RequestTrackingClient implements ServiceClient
+  {
+    private final ServiceClient delegate;
+    private final Set<ListenableFuture<?>> activeRequests;
+
+    private RequestTrackingClient(
+        final ServiceClient delegate,
+        final Set<ListenableFuture<?>> activeRequests
+    )
+    {
+      this.delegate = delegate;
+      this.activeRequests = activeRequests;
+    }
+
+    @Override
+    public <IntermediateType, FinalType> ListenableFuture<FinalType> 
asyncRequest(
+        final RequestBuilder requestBuilder,
+        final HttpResponseHandler<IntermediateType, FinalType> handler
+    )
+    {
+      final ListenableFuture<FinalType> future = 
delegate.asyncRequest(requestBuilder, handler);
+      activeRequests.add(future);
+      future.addListener(() -> activeRequests.remove(future), 
MoreExecutors.directExecutor());
+      return future;
+    }
+
+    @Override
+    public ServiceClient withRetryPolicy(final ServiceRetryPolicy retryPolicy)
+    {
+      return new RequestTrackingClient(delegate.withRetryPolicy(retryPolicy), 
activeRequests);
+    }
+  }
+
   /**
    * Service client that adds the {@link 
DartWorkerResource#HEADER_CONTROLLER_HOST} header.
    */
diff --git 
a/multi-stage-query/src/main/java/org/apache/druid/msq/exec/ControllerImpl.java 
b/multi-stage-query/src/main/java/org/apache/druid/msq/exec/ControllerImpl.java
index bd87333e532..3e8d1f8ba18 100644
--- 
a/multi-stage-query/src/main/java/org/apache/druid/msq/exec/ControllerImpl.java
+++ 
b/multi-stage-query/src/main/java/org/apache/druid/msq/exec/ControllerImpl.java
@@ -437,6 +437,7 @@ public class ControllerImpl implements Controller
           MultiStageQueryContext.getSketchEncoding(querySpec.getContext())
       );
       closer.register(workerSketchFetcher::close);
+      closer.register(() -> workerManager.stop(true));
 
       // Execution-related: run the multi-stage QueryDefinition.
       final InputSpecSlicerFactory inputSpecSlicerFactory = 
makeInputSpecSlicerFactory(
@@ -2542,7 +2543,6 @@ public class ControllerImpl implements Controller
       log.debug("Query [%s] starting task launcher.", queryDef.getQueryId());
 
       workerTaskLauncherFuture = workerManager.start();
-      closer.register(() -> workerManager.stop(true));
 
       workerTaskLauncherFuture.addListener(
           () ->
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/dart/controller/DartWorkerManagerTest.java
 
b/multi-stage-query/src/test/java/org/apache/druid/msq/dart/controller/DartWorkerManagerTest.java
index a685a1457b1..8f8639b79cf 100644
--- 
a/multi-stage-query/src/test/java/org/apache/druid/msq/dart/controller/DartWorkerManagerTest.java
+++ 
b/multi-stage-query/src/test/java/org/apache/druid/msq/dart/controller/DartWorkerManagerTest.java
@@ -23,11 +23,13 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
 import it.unimi.dsi.fastutil.ints.IntSet;
 import org.apache.druid.common.guava.FutureUtils;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.indexer.TaskState;
 import org.apache.druid.java.util.common.ISE;
+import org.apache.druid.java.util.common.concurrent.Execs;
 import org.apache.druid.msq.dart.worker.DartWorkerClientImpl;
 import org.apache.druid.msq.dart.worker.WorkerId;
 import org.apache.druid.msq.exec.WorkerManager;
@@ -43,6 +45,11 @@ import org.mockito.MockitoAnnotations;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 
 public class DartWorkerManagerTest
 {
@@ -199,4 +206,40 @@ public class DartWorkerManagerTest
     // Ensure the future from start() resolves.
     Assertions.assertNull(FutureUtils.getUnchecked(future, true));
   }
+
+  @Test
+  public void test_concurrentStop_waitsForFirstStop() throws Exception
+  {
+    final SettableFuture<Void> stopFuture = SettableFuture.create();
+    final CountDownLatch stopRequested = new CountDownLatch(1);
+    
Mockito.when(workerClient.stopWorker(WORKERS.get(0))).thenAnswer(invocation -> {
+      stopRequested.countDown();
+      return stopFuture;
+    });
+    
Mockito.when(workerClient.stopWorker(WORKERS.get(1))).thenReturn(Futures.immediateFuture(null));
+
+    workerManager.start();
+    final ExecutorService stopExecutor = Execs.multiThreaded(2, 
"dart-worker-manager-stop-test-%s");
+
+    try {
+      final Future<?> firstStop = stopExecutor.submit(() -> 
workerManager.stop(true));
+      Assertions.assertTrue(stopRequested.await(10, TimeUnit.SECONDS));
+
+      final Future<?> concurrentStop = stopExecutor.submit(() -> 
workerManager.stop(true));
+      Assertions.assertThrows(
+          TimeoutException.class,
+          () -> concurrentStop.get(1, TimeUnit.SECONDS),
+          "Concurrent stop returned before the first stop completed"
+      );
+
+      stopFuture.set(null);
+      firstStop.get(10, TimeUnit.SECONDS);
+      concurrentStop.get(10, TimeUnit.SECONDS);
+    }
+    finally {
+      stopFuture.set(null);
+      stopExecutor.shutdownNow();
+      Assertions.assertTrue(stopExecutor.awaitTermination(10, 
TimeUnit.SECONDS));
+    }
+  }
 }
diff --git 
a/multi-stage-query/src/test/java/org/apache/druid/msq/dart/worker/DartWorkerClientImplTest.java
 
b/multi-stage-query/src/test/java/org/apache/druid/msq/dart/worker/DartWorkerClientImplTest.java
index df38d020818..dc829267944 100644
--- 
a/multi-stage-query/src/test/java/org/apache/druid/msq/dart/worker/DartWorkerClientImplTest.java
+++ 
b/multi-stage-query/src/test/java/org/apache/druid/msq/dart/worker/DartWorkerClientImplTest.java
@@ -19,10 +19,15 @@
 
 package org.apache.druid.msq.dart.worker;
 
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.SettableFuture;
 import org.apache.druid.error.DruidException;
 import org.apache.druid.java.util.common.concurrent.Execs;
 import org.apache.druid.java.util.http.client.HttpClient;
+import org.apache.druid.msq.kernel.WorkOrder;
 import org.apache.druid.rpc.ServiceClient;
+import org.apache.druid.rpc.ServiceClientFactory;
 import org.apache.druid.rpc.ServiceClientFactoryImpl;
 import org.apache.druid.rpc.ServiceClosedException;
 import org.apache.druid.segment.TestHelper;
@@ -106,6 +111,100 @@ public class DartWorkerClientImplTest
     Assertions.assertThrows(DruidException.class, () -> 
workerClient.getClient(WORKER_ID.toString()));
   }
 
+  @Test
+  public void test_stopWorker_cancelsActiveWorkOrder()
+  {
+    final SettableFuture<Void> workOrderFuture = SettableFuture.create();
+    final ServiceClient serviceClient = Mockito.mock(ServiceClient.class);
+    
Mockito.<ListenableFuture<Void>>when(serviceClient.asyncRequest(Mockito.any(), 
Mockito.any()))
+           .thenReturn(workOrderFuture, Futures.immediateFuture(null));
+
+    final ServiceClientFactory clientFactory = 
Mockito.mock(ServiceClientFactory.class);
+    Mockito.when(clientFactory.makeClient(Mockito.any(), Mockito.any(), 
Mockito.any())).thenReturn(serviceClient);
+
+    final DartWorkerClientImpl client = new DartWorkerClientImpl(
+        QUERY_ID,
+        clientFactory,
+        TestHelper.makeSmileMapper(),
+        "localhost:8080"
+    );
+
+    try {
+      client.postWorkOrder(WORKER_ID.toString(), 
Mockito.mock(WorkOrder.class));
+      client.stopWorker(WORKER_ID.toString());
+
+      Assertions.assertTrue(workOrderFuture.isCancelled());
+    }
+    finally {
+      client.close();
+    }
+  }
+
+  @Test
+  public void test_postWorkOrder_afterStop_isCancelled()
+  {
+    final SettableFuture<Void> workOrderFuture = SettableFuture.create();
+    final ServiceClient serviceClient = Mockito.mock(ServiceClient.class);
+    
Mockito.<ListenableFuture<Void>>when(serviceClient.asyncRequest(Mockito.any(), 
Mockito.any()))
+           .thenReturn(Futures.immediateFuture(null), workOrderFuture);
+
+    final ServiceClientFactory clientFactory = 
Mockito.mock(ServiceClientFactory.class);
+    Mockito.when(clientFactory.makeClient(Mockito.any(), Mockito.any(), 
Mockito.any())).thenReturn(serviceClient);
+
+    final DartWorkerClientImpl client = new DartWorkerClientImpl(
+        QUERY_ID,
+        clientFactory,
+        TestHelper.makeSmileMapper(),
+        "localhost:8080"
+    );
+
+    try {
+      client.stopWorker(WORKER_ID.toString());
+      final ListenableFuture<Void> returned = client.postWorkOrder(
+          WORKER_ID.toString(),
+          Mockito.mock(WorkOrder.class)
+      );
+
+      Assertions.assertTrue(returned.isCancelled());
+      Assertions.assertFalse(workOrderFuture.isCancelled());
+    }
+    finally {
+      client.close();
+    }
+  }
+
+  @Test
+  public void test_closeCancelsActiveRequests()
+  {
+    final SettableFuture<Void> finishFuture = SettableFuture.create();
+    final SettableFuture<Void> stopFuture = SettableFuture.create();
+    final ServiceClient serviceClient = Mockito.mock(ServiceClient.class);
+    
Mockito.<ListenableFuture<Void>>when(serviceClient.asyncRequest(Mockito.any(), 
Mockito.any()))
+           .thenReturn(finishFuture, stopFuture);
+
+    final ServiceClientFactory clientFactory = 
Mockito.mock(ServiceClientFactory.class);
+    Mockito.when(clientFactory.makeClient(Mockito.any(), Mockito.any(), 
Mockito.any())).thenReturn(serviceClient);
+
+    final DartWorkerClientImpl client = new DartWorkerClientImpl(
+        QUERY_ID,
+        clientFactory,
+        TestHelper.makeSmileMapper(),
+        "localhost:8080"
+    );
+
+    try {
+      client.postFinish(WORKER_ID.toString());
+      client.stopWorker(WORKER_ID.toString());
+      client.close();
+
+      Assertions.assertTrue(finishFuture.isCancelled());
+      Assertions.assertTrue(stopFuture.isCancelled());
+    }
+    finally {
+      client.close();
+    }
+  }
+
   /**
    * Verify that a request to {@link #WORKER_ID} fails immediately, rather 
than retrying, due to its client
    * being closed.


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

Reply via email to