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

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


The following commit(s) were added to refs/heads/master by this push:
     new d60d94c7582 [fix](arrow-flight) Deduplicate parallel result endpoints 
by backend (#69780)
d60d94c7582 is described below

commit d60d94c7582964e4f82396c5be30aaabd429a2c9
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Oct 29 04:57:11 2026 +0000

    [fix](arrow-flight) Deduplicate parallel result endpoints by backend 
(#69780)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #68642
    
    Problem Summary:
    
    #### In short
    
    This ports branch-4.x's #68642 to master. With parallel result sink on,
    which is the master default, the result fragment's instances on one BE
    all write into one result buffer, and the Flight ticket names that
    buffer only by query id and BE address. The coordinator still published
    one Flight endpoint per instance, so a query with 8 instances per BE
    returned 8 identical tickets for every BE. This PR publishes one
    endpoint per result backend in that mode.
    
    #### Background
    
    - An Arrow Flight SQL query leaves its result on the BEs.
    `NereidsCoordinator.setForArrowFlight` registers where to pull it
    (`FlightSqlEndpointsLocation`), and `DorisFlightSqlProducer` turns every
    registered location into one `FlightEndpoint`, whose ticket is `<id>&<BE
    brpc host>&<BE brpc port>&<sql>`.
    - With `enable_parallel_result_sink` (default `true`), every instance of
    the result fragment on a BE writes into one result buffer registered
    under the query id, and `<id>` is the query id. Without it, each
    instance has its own buffer under its instance id.
    - A Flight SQL client reads every endpoint of the `FlightInfo` it gets
    back, each through its own `DoGet` stream.
    
    #### The problem, and what it cost
    
    `setForArrowFlight` added one location per instance in both modes. In
    query-id mode that is one copy of the same ticket per instance: 6 BEs
    with 8 instances each published 48 endpoints for 6 result buffers. A
    client reading all of them opened 8 streams on each buffer, and
    `ArrowFlightResultBlockBuffer::get_arrow_batch` hands each batch to
    whichever stream asks first, so the rows of one buffer were split across
    8 streams that were presented as separate result partitions under the
    same ticket. The remote Doris catalog was not affected:
    `RemoteDorisScanNode.processFlightEndpoints` already drops repeated
    ticket and location pairs.
    
    | Parallel result sink, 1 BE, 8 instances | Endpoints | Distinct tickets
    |
    |---|---|---|
    | Before this PR | 8 | 1 |
    | After this PR | 1 | 1 |
    
    #### How this PR fixes it
    
    - In query-id mode `setForArrowFlight` keeps a set of the backend ids it
    has published and skips an instance whose backend is already in it, so
    each result backend gets one endpoint, in the order the backends first
    appear. The key is the backend id, not the Flight address, so two BEs
    whose Flight addresses are equal still get one endpoint each.
    - Instance-id mode (parallel result sink off) is unchanged: every
    instance owns its buffer and keeps its own endpoint. A result returned
    from the FE publishes no endpoint, as before.
    
    #### Results
    
    - `NereidsCoordinatorFlightEndpointsTest` (5 tests): 8 parallel
    instances on one BE give one endpoint carrying the query id; 6 BEs × 8
    instances give 6 endpoints in BE order; two BEs sharing a Flight address
    keep two endpoints; parallel result sink off keeps one endpoint per
    instance id; a local result publishes none. Without the fix 3 of them
    fail (`expected: <6> but was: <48>`, `<1>` vs `<8>`, `<2>` vs `<4>`);
    keying the set on the Flight address instead of the backend fails 2.
    - `arrow_flight_sql_p0/test_flight_parallel_partitions` runs a 60-bucket
    query with `parallel_pipeline_task_num=8`, with parallel result sink on
    and off, and reads the endpoints one after another and concurrently:
    tickets must be unique, one endpoint per result BE in query-id mode, and
    all 60000 rows must arrive exactly once. On a local single-BE cluster it
    fails before this PR with `Duplicate Flight result tickets ==> expected:
    <8> but was: <1>` and passes with it.
    
    Co-authored-by: Gabriel <[email protected]>
---
 .../org/apache/doris/qe/NereidsCoordinator.java    |   7 ++
 .../qe/NereidsCoordinatorFlightEndpointsTest.java  | 121 +++++++++++++++++++
 .../test_flight_parallel_partitions.groovy         | 134 +++++++++++++++++++++
 3 files changed, 262 insertions(+)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/qe/NereidsCoordinator.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/NereidsCoordinator.java
index b7715396048..a21f6f30f72 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/NereidsCoordinator.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/NereidsCoordinator.java
@@ -74,6 +74,7 @@ import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
 import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -537,6 +538,7 @@ public class NereidsCoordinator extends Coordinator {
         if (dataSink instanceof ResultSink || dataSink instanceof 
ResultFileSink) {
             // The client pulls the result from the backend (Arrow Flight 
SQL); register where.
             if (connectContext != null && 
!connectContext.isReturnResultFromLocal()) {
+                Set<Long> resultBackendIds = new HashSet<>();
                 for (AssignedJob instance : topPlan.getInstanceJobs()) {
                     BackendWorker worker = (BackendWorker) 
instance.getAssignedWorker();
                     Backend backend = worker.getBackend();
@@ -545,6 +547,11 @@ public class NereidsCoordinator extends Coordinator {
                     }
                     TUniqueId finstId;
                     if 
(connectContext.getSessionVariable().enableParallelResultSink()) {
+                        // Parallel instances on a BE share one query-id 
buffer, so their tickets
+                        // cannot be published as independently consumable 
result partitions.
+                        if (!resultBackendIds.add(worker.id())) {
+                            continue;
+                        }
                         finstId = getQueryId();
                     } else {
                         finstId = instance.instanceId();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/qe/NereidsCoordinatorFlightEndpointsTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/qe/NereidsCoordinatorFlightEndpointsTest.java
new file mode 100644
index 00000000000..0289b09d07b
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/qe/NereidsCoordinatorFlightEndpointsTest.java
@@ -0,0 +1,121 @@
+// 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.doris.qe;
+
+import org.apache.doris.arrowflight.protocol.FlightProtocolAdapter;
+import org.apache.doris.arrowflight.results.FlightSqlEndpointsLocation;
+import org.apache.doris.nereids.trees.plans.distribute.PipelineDistributedPlan;
+import org.apache.doris.nereids.trees.plans.distribute.worker.BackendWorker;
+import org.apache.doris.nereids.trees.plans.distribute.worker.job.AssignedJob;
+import org.apache.doris.planner.ResultSink;
+import org.apache.doris.system.Backend;
+import org.apache.doris.thrift.TNetworkAddress;
+import org.apache.doris.thrift.TUniqueId;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+
+public class NereidsCoordinatorFlightEndpointsTest {
+    private static final TUniqueId QUERY_ID = new TUniqueId(1, 2);
+
+    @Test
+    public void parallelInstancesShareOneEndpointPerBackend() throws Exception 
{
+        List<FlightSqlEndpointsLocation> endpoints = endpoints(true, false, 1, 
8);
+        Assertions.assertEquals(1, endpoints.size());
+        Assertions.assertEquals(QUERY_ID, endpoints.get(0).getFinstId());
+    }
+
+    @Test
+    public void parallelEndpointsRetainAllResultBackendsInOrder() throws 
Exception {
+        List<FlightSqlEndpointsLocation> endpoints = endpoints(true, false, 6, 
8);
+        Assertions.assertEquals(6, endpoints.size());
+        for (int i = 0; i < endpoints.size(); i++) {
+            Assertions.assertEquals(QUERY_ID, endpoints.get(i).getFinstId());
+            Assertions.assertEquals(new TNetworkAddress("127.0.0.1", 8100 + i),
+                    endpoints.get(i).getResultInternalServiceAddr());
+        }
+    }
+
+    @Test
+    public void sharedFlightAddressDoesNotMergeDifferentBackends() throws 
Exception {
+        List<FlightSqlEndpointsLocation> endpoints = endpoints(true, false, 2, 
2);
+        Assertions.assertEquals(2, endpoints.size());
+        Assertions.assertEquals(endpoints.get(0).getResultFlightServerAddr(),
+                endpoints.get(1).getResultFlightServerAddr());
+        
Assertions.assertNotEquals(endpoints.get(0).getResultInternalServiceAddr(),
+                endpoints.get(1).getResultInternalServiceAddr());
+    }
+
+    @Test
+    public void nonParallelEndpointsKeepDistinctInstanceIds() throws Exception 
{
+        List<FlightSqlEndpointsLocation> endpoints = endpoints(false, false, 
1, 2);
+        Assertions.assertEquals(2, endpoints.size());
+        Assertions.assertEquals(new TUniqueId(2, 0), 
endpoints.get(0).getFinstId());
+        Assertions.assertEquals(new TUniqueId(2, 1), 
endpoints.get(1).getFinstId());
+    }
+
+    @Test
+    public void localResultDoesNotPublishFlightEndpoints() throws Exception {
+        Assertions.assertTrue(endpoints(true, true, 2, 8).isEmpty());
+    }
+
+    private List<FlightSqlEndpointsLocation> endpoints(boolean parallel, 
boolean local,
+            int backendCount, int instancesPerBackend) throws Exception {
+        ConnectContext context = 
ConnectContext.forFlight("test-peer-identity");
+        if (!local) {
+            // A query's result stays on the backends; the coordinator 
registers where to pull it.
+            FlightProtocolAdapter.of(context).beforeQuery(context);
+        }
+        context.getSessionVariable().setEnableParallelResultSink(parallel);
+        CoordinatorContext coordinatorContext = 
Mockito.mock(CoordinatorContext.class);
+        setContextField(coordinatorContext, "connectContext", context);
+        setContextField(coordinatorContext, "dataSink", 
Mockito.mock(ResultSink.class));
+        PipelineDistributedPlan plan = 
Mockito.mock(PipelineDistributedPlan.class, Mockito.RETURNS_DEEP_STUBS);
+        List<AssignedJob> jobs = new ArrayList<>();
+        for (int i = 0; i < backendCount; i++) {
+            Backend backend = new Backend(i + 1, "127.0.0.1", 9000 + i);
+            backend.setBrpcPort(8100 + i);
+            // Backend identity must remain distinct even when Flight 
locations are shared.
+            backend.setArrowFlightSqlPort(8050);
+            for (int j = 0; j < instancesPerBackend; j++) {
+                AssignedJob job = Mockito.mock(AssignedJob.class);
+                Mockito.when(job.getAssignedWorker()).thenReturn(new 
BackendWorker(0, backend));
+                Mockito.when(job.instanceId()).thenReturn(new TUniqueId(2, i * 
instancesPerBackend + j));
+                jobs.add(job);
+            }
+        }
+        
Mockito.when(plan.getInstanceJobs()).thenReturn(ImmutableList.copyOf(jobs));
+        
Mockito.when(plan.getFragmentJob().getFragment().getOutputExprs()).thenReturn(new
 ArrayList<>());
+        NereidsCoordinator coordinator = 
Mockito.mock(NereidsCoordinator.class, Mockito.CALLS_REAL_METHODS);
+        Mockito.doReturn(QUERY_ID).when(coordinator).getQueryId();
+        coordinator.processTopSink(coordinatorContext, plan);
+        return context.getFlightSqlEndpointsLocations();
+    }
+
+    private void setContextField(CoordinatorContext context, String name, 
Object value) throws Exception {
+        Field field = CoordinatorContext.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(context, value);
+    }
+}
diff --git 
a/regression-test/suites/arrow_flight_sql_p0/test_flight_parallel_partitions.groovy
 
b/regression-test/suites/arrow_flight_sql_p0/test_flight_parallel_partitions.groovy
new file mode 100644
index 00000000000..005341df0ee
--- /dev/null
+++ 
b/regression-test/suites/arrow_flight_sql_p0/test_flight_parallel_partitions.groovy
@@ -0,0 +1,134 @@
+// 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.
+
+import org.apache.arrow.driver.jdbc.shaded.com.google.protobuf.Any
+import org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.CallOptions
+import org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.FlightClient
+import org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.Location
+import 
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.sql.FlightSqlClient
+import 
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.sql.impl.FlightSql
+import 
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.memory.RootAllocator
+
+import java.util.concurrent.Callable
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+suite("test_flight_parallel_partitions", "arrow_flight_sql") {
+    def frontend = jdbc_sql_return_maparray("SHOW FRONTENDS").find {
+        it.IsMaster.toString().equalsIgnoreCase("true") && 
it.Alive.toString().equalsIgnoreCase("true")
+    }
+    assertNotNull(frontend)
+    assertTrue(frontend.ArrowFlightSqlPort.toString().toInteger() > 0)
+    def database = jdbc_sql("SELECT DATABASE()")[0][0]
+    def table = "${database}.flight_parallel_partition_source"
+    def allocator = new RootAllocator(Long.MAX_VALUE)
+    def feClient = FlightClient.builder(allocator,
+            Location.forGrpcInsecure(frontend.Host.toString(), 
frontend.ArrowFlightSqlPort.toString().toInteger())).build()
+    def client = new FlightSqlClient(feClient)
+    def auth
+    def readEndpoint = { endpoint, boolean collectRows ->
+        def rows = []
+        FlightClient.builder(allocator, 
endpoint.locations[0]).build().withCloseable { beClient ->
+            beClient.getStream(endpoint.ticket, auth, CallOptions.timeout(30, 
TimeUnit.SECONDS)).withCloseable { stream ->
+                while (stream.next()) {
+                    if (collectRows) {
+                        def vector = stream.root.getVector(0)
+                        for (int i = 0; i < stream.root.rowCount; i++) {
+                            rows.add(((Number) 
vector.getObject(i)).longValue())
+                        }
+                    }
+                }
+            }
+        }
+        rows
+    }
+    def executeSetting = { String statement ->
+        client.execute(statement, auth).endpoints.each { readEndpoint(it, 
false) }
+    }
+    try {
+        auth = 
feClient.authenticateBasicToken(context.config.otherConfigs.get("extArrowFlightSqlUser"),
+                
context.config.otherConfigs.get("extArrowFlightSqlPassword")).get()
+        executeSetting("SET enable_sql_cache=false")
+        executeSetting("SET enable_nereids_distribute_planner=true")
+        executeSetting("SET parallel_pipeline_task_num=8")
+        executeSetting("SET query_timeout=60")
+        jdbc_sql("DROP TABLE IF EXISTS ${table}")
+        jdbc_sql("CREATE TABLE ${table} (id BIGINT NOT NULL) DISTRIBUTED BY 
HASH(id) BUCKETS 60 " +
+                "PROPERTIES(\"replication_num\"=\"1\")")
+        jdbc_sql("INSERT INTO ${table} SELECT number FROM 
numbers(\"number\"=\"60\")")
+        def resultBackendCount = jdbc_sql_return_maparray("SHOW TABLETS FROM 
${table}")
+                .collect { it.BackendId }.unique().size()
+        [true, false].each { parallel ->
+            executeSetting("SET enable_parallel_result_sink=${parallel}")
+            [false, true].each { concurrent ->
+                // Each execution owns fresh tickets; consuming a ticket does 
not create a replayable partition.
+                def info = client.execute("SELECT id * 1000 + n AS sequence_id 
FROM ${table} " +
+                        "LATERAL VIEW explode_numbers(1000) expanded AS n", 
auth)
+                assertTrue(!info.endpoints.isEmpty())
+                def tickets = info.endpoints.collect { 
Base64.encoder.encodeToString(it.ticket.bytes) }
+                assertEquals(tickets.size(), tickets.toSet().size(), 
"Duplicate Flight result tickets")
+                if (parallel) {
+                    def resultAddresses = info.endpoints.collect { endpoint ->
+                        def fields = 
Any.parseFrom(endpoint.ticket.bytes).unpack(FlightSql.TicketStatementQuery.class)
+                                .statementHandle.toStringUtf8().split("&")
+                        "${fields[1]}:${fields[2]}".toString()
+                    }
+                    // Instance parallelism must not publish multiple readers 
for the same BE result buffer.
+                    assertEquals(info.endpoints.size(), 
resultAddresses.toSet().size())
+                    if (resultBackendCount > 1) {
+                        assertTrue(info.endpoints.size() > 1, "Expected 
multiple result backends")
+                    }
+                } else {
+                    assertEquals(1, info.endpoints.size())
+                }
+                def rows = []
+                if (concurrent) {
+                    def executor = Executors.newFixedThreadPool(Math.min(8, 
info.endpoints.size()))
+                    try {
+                        def futures = info.endpoints.collect { endpoint ->
+                            executor.submit({ readEndpoint(endpoint, true) } 
as Callable)
+                        }
+                        futures.each { rows.addAll(it.get(60, 
TimeUnit.SECONDS)) }
+                    } finally {
+                        executor.shutdownNow()
+                        assertTrue(executor.awaitTermination(35, 
TimeUnit.SECONDS))
+                    }
+                } else {
+                    info.endpoints.each { rows.addAll(readEndpoint(it, true)) }
+                }
+                assertEquals(60000, rows.size())
+                assertEquals((0L..<60000L).toList(), rows.sort())
+            }
+        }
+    } finally {
+        try {
+            if (auth != null) {
+                feClient.closeSession(new 
org.apache.arrow.driver.jdbc.shaded.org.apache.arrow.flight.CloseSessionRequest(),
 auth)
+            }
+        } finally {
+            try {
+                client.close()
+            } finally {
+                try {
+                    allocator.close()
+                } finally {
+                    jdbc_sql("DROP TABLE IF EXISTS ${table}")
+                }
+            }
+        }
+    }
+}


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

Reply via email to