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 d642b7c5a3a [fix](connector) rebuild connector scan properties after 
column pruning (#66369)
d642b7c5a3a is described below

commit d642b7c5a3acc3ef666852c683f875f3fd6b1fa9
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Aug 4 10:11:19 2026 +0800

    [fix](connector) rebuild connector scan properties after column pruning 
(#66369)
    
    ### What problem does this PR solve?
    
    Related PR: #64304 (catalog SPI)
    
    Problem Summary:
    
    A connector never decides which columns to read — it renders whatever
    list `ConnectorScanRequest.getColumns()` carries. The jdbc connector
    turns that list verbatim into the remote `SELECT` list and falls back to
    `SELECT *` when it is empty (`JdbcQueryBuilder#buildQuery`). So column
    pruning for every plugin-driven external scan rests entirely on
    `PluginDrivenScanNode#buildColumnHandles()`, which intersects the
    connector's column handles with this scan's tuple slots.
    
    That method had **no direct coverage**, and its failure mode (projecting
    more columns than the query needs) is a pure performance regression that
    no result-comparing test can observe. The existing jdbc explain
    assertions all pass a column list and assert those same columns are
    present; none of them can fail on an over-wide projection they did not
    anticipate.
    
    This PR started as coverage for that gap. The new coverage immediately
    found a real bug, so it now carries the fix as well.
    
    **1. The bug: the connector's scan properties are computed before column
    pruning**
    
    A plugin-driven scan asks its connector for one property bundle — the
    jdbc remote `SELECT`, per-column dictionaries, file format, path
    partition keys — and caches it (`cachedPropertiesResult` /
    `scanNodeProperties`).
    
    That cache is first filled from `init()`:
    
    ```
    PhysicalPlanTranslator#getPlanFragmentForPhysicalFileScan
      -> scanNode.init()
         -> FileQueryScanNode#doInitialize -> initSchemaParams -> 
getPathPartitionKeys()
            -> PluginDrivenScanNode#getPathPartitionKeys -> 
getOrLoadScanNodeProperties()
    ```
    
    `init()` runs while the translator is still translating this scan —
    strictly **before** the project above it prunes the tuple down to the
    columns the query reads (`updateScanSlotsMaterialization`). Everything
    the connector derives from the projection at that point therefore
    describes the **full table schema**.
    
    The only thing that dropped the cache was `convertPredicate()`, and only
    when there was a conjunct to push down. Queries with a `WHERE` clause
    were rebuilt from the pruned tuple by accident; filter-less ones kept
    the pre-pruning bundle. Result:
    
    ```sql
    -- doris_test.test1 has 12 columns
    explain select count(*) from test1;
    -- before: QUERY: SELECT `k1`, `k2`, ..., `k12` FROM `doris_test`.`test1`
    -- after:  QUERY: SELECT `k1` FROM `doris_test`.`test1`
    ```
    
    This holds for **every** `WHERE`-less query on any plugin-driven
    external table, not just `count(*)`.
    
    The scan itself was not affected — `getSplits()` rebuilds the column
    handles from the final tuple, so the query actually sent to the source
    was already pruned. What was wrong is the reported remote query, and
    anything else a connector derives from the projection through this
    bundle (`populateScanLevelParams`, `getFileAttributes`). Reviewers of
    the iceberg connector may want to check the field-id dictionary applied
    in `IcebergScanPlanProvider#populateScanLevelParams`, which is built
    from the requested columns and, on a filter-less query, was built over
    the full schema.
    
    Fix: drop the cache in `doFinalize()`, the first point at which the
    tuple is final. Every filtered query already exercises this rebuild path
    today — including the second MVCC-snapshot / rewrite-scope pin it
    implies — so the filter-less path is only being moved onto an
    already-exercised path.
    
    Why it survived since the SPI migration: all 13 remote-query assertions
    in the tree filter, and a filter is exactly what used to hide this. The
    one filter-less assertion that exists (`test_gbase_jdbc_catalog`,
    commented out) expects the pruned single column, i.e. the behavior this
    restores.
    
    **2. The projection decision itself (`fe-core`)**
    
    `PluginDrivenScanNodeColumnPruningTest` drives the real
    `buildColumnHandles()` and pins:
    
    - only tuple-slot columns are projected (3-column table, 1 requested →
    exactly 1 handle);
    - the order follows the slot order, not the connector's handle-map order
    — the connector renders this list positionally;
    - slots with no backing column, and slots with no matching handle, are
    skipped rather than leaking into the list;
    - an empty tuple projects nothing — the sole input that reaches the
    connector's `SELECT *` fallback.
    
    Every assertion was mutation-checked against the production method:
    returning `allHandles.values()` kills 4 of the 5, and making the
    unmatched-slot path fail loud unconditionally kills the 5th.
    
    **3. Explain assertions (`external_table_p0`)**
    
    Two additions to `test_mysql_jdbc_catalog`, both of which fail without
    the fix above:
    
    - a filter-less projection (`select k8 from test1`) — the shape no
    existing assertion covered, and the most direct pin for the caching bug;
    - `count(*)`, the one shape whose projection would otherwise go empty.
    The engine keeps a single smallest slot
    (`PhysicalPlanTranslator#updateScanSlotsMaterialization`) instead of
    letting the tuple go empty, and an empty tuple is exactly what makes the
    jdbc connector emit `SELECT *`. The assertion pins that the remote
    select list stays one column wide and is not `*`; it deliberately does
    **not** pin which column wins, since that is `getSmallestSlot`'s
    business and tracks type widths.
---
 .../datasource/scan/PluginDrivenScanNode.java      |  24 +++
 .../PluginDrivenScanNodeColumnPruningTest.java     | 178 +++++++++++++++++++++
 .../jdbc/test_mysql_jdbc_catalog.groovy            |  29 ++++
 3 files changed, 231 insertions(+)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
index 910aa25e85d..5f2d7c49abe 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
@@ -867,6 +867,30 @@ public class PluginDrivenScanNode extends 
FileQueryScanNode {
         return attrs;
     }
 
+    /**
+     * Drops the connector's cached scan-node properties before finalizing, 
because they were computed
+     * against a tuple that no longer describes this scan.
+     *
+     * <p>The cache is first filled during {@code init()} — {@code 
FileQueryScanNode.initSchemaParams()}
+     * asks for {@link #getPathPartitionKeys()}, which loads the whole 
property bundle. That happens while
+     * {@code PhysicalPlanTranslator} is still translating THIS scan, i.e. 
strictly BEFORE the project
+     * above it prunes the tuple down to the columns the query actually reads
+     * ({@code updateScanSlotsMaterialization}). So everything the connector 
derived from the projection at
+     * that point — the jdbc remote {@code SELECT} list, per-column 
dictionaries — describes the FULL
+     * schema. Finalize is the first moment the tuple is final, so the bundle 
is rebuilt from here.</p>
+     *
+     * <p>Queries with a filter were getting this by accident: {@link 
#convertPredicate()} invalidates the
+     * same cache for its own reason, and it runs first. Filter-less queries 
hit its empty-conjuncts early
+     * return and kept the pre-pruning bundle, which is why {@code EXPLAIN} 
reported a full-width remote
+     * query for e.g. {@code select count(*) from tbl} while the scan itself 
read one column.</p>
+     */
+    @Override
+    protected void doFinalize() throws UserException {
+        scanNodeProperties = null;
+        cachedPropertiesResult = null;
+        super.doFinalize();
+    }
+
     @Override
     protected void convertPredicate() {
         // Attempt filter pushdown via the connector SPI
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeColumnPruningTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeColumnPruningTest.java
new file mode 100644
index 00000000000..b3de3b882d3
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeColumnPruningTest.java
@@ -0,0 +1,178 @@
+// 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.datasource.scan;
+
+import org.apache.doris.analysis.SlotDescriptor;
+import org.apache.doris.analysis.TupleDescriptor;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.catalog.TableIf;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.connector.api.ConnectorMetadata;
+import org.apache.doris.connector.api.handle.ConnectorColumnHandle;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Guards {@link PluginDrivenScanNode}'s column projection — the engine-side 
half of connector column
+ * pruning.
+ *
+ * <p><b>Why this matters:</b> a connector never decides which columns to 
read; it renders whatever list
+ * {@code ConnectorScanRequest.getColumns()} carries (e.g. the jdbc connector 
turns it verbatim into the
+ * remote {@code SELECT} list, falling back to {@code SELECT *} when it is 
empty). That list is produced
+ * HERE, by intersecting the connector's column handles with this scan's tuple 
slots — the slots Nereids'
+ * {@code ColumnPruning} + {@code 
PhysicalPlanTranslator.updateScanSlotsMaterialization} already pruned to
+ * what the query needs. So every "pruning stopped working" failure mode lands 
in this method, and it had
+ * no direct coverage: returning all handles instead of the slot-matched ones 
silently turns every external
+ * scan into a full-width read (for jdbc, a literal {@code SELECT *} against 
the remote database), which is
+ * a pure performance regression that no result-comparing test can see.</p>
+ *
+ * <p>The projection is driven through the real private {@code 
buildColumnHandles} on a
+ * {@code CALLS_REAL_METHODS} node with only its collaborator fields injected: 
with no {@code ConnectContext}
+ * the MVCC lookup resolves empty and the pinned-schema branch (covered by
+ * {@link PluginDrivenScanNodePinnedSchemaTest}) is not taken, leaving exactly 
the slot-intersection logic
+ * under test.</p>
+ */
+public class PluginDrivenScanNodeColumnPruningTest {
+
+    private static SlotDescriptor slotFor(String columnName) {
+        SlotDescriptor slot = Mockito.mock(SlotDescriptor.class);
+        Mockito.when(slot.getColumn()).thenReturn(new Column(columnName, 
PrimitiveType.INT));
+        return slot;
+    }
+
+    /** A slot with no backing table column (a synthetic/derived slot), which 
must never be projected. */
+    private static SlotDescriptor slotWithoutColumn() {
+        SlotDescriptor slot = Mockito.mock(SlotDescriptor.class);
+        Mockito.when(slot.getColumn()).thenReturn(null);
+        return slot;
+    }
+
+    /**
+     * A node whose connector metadata exposes {@code handles} for the table 
and whose tuple carries
+     * {@code slots}. {@code cachedMetadata} is injected directly so the 
per-statement metadata funnel
+     * (which needs a live session scope) stays out of this test.
+     */
+    private static PluginDrivenScanNode nodeWith(Map<String, 
ConnectorColumnHandle> handles,
+            SlotDescriptor... slots) {
+        PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class, 
Mockito.CALLS_REAL_METHODS);
+
+        ConnectorMetadata metadata = Mockito.mock(ConnectorMetadata.class);
+        Mockito.when(metadata.getColumnHandles(Mockito.any(), 
Mockito.any())).thenReturn(handles);
+        Deencapsulation.setField(node, "cachedMetadata", metadata);
+
+        TupleDescriptor desc = Mockito.mock(TupleDescriptor.class);
+        ArrayList<SlotDescriptor> slotList = new ArrayList<>();
+        for (SlotDescriptor slot : slots) {
+            slotList.add(slot);
+        }
+        Mockito.when(desc.getSlots()).thenReturn(slotList);
+        // getTargetTable() reads through the tuple; a plain table keeps this 
off both the sys-table pin
+        // and the time-travel pinned-schema branches.
+        Mockito.when(desc.getTable()).thenReturn(Mockito.mock(TableIf.class));
+        Deencapsulation.setField(node, "desc", desc);
+
+        return node;
+    }
+
+    private static Map<String, ConnectorColumnHandle> handles(String... names) 
{
+        Map<String, ConnectorColumnHandle> map = new LinkedHashMap<>();
+        for (String name : names) {
+            map.put(name, Mockito.mock(ConnectorColumnHandle.class, "handle:" 
+ name));
+        }
+        return map;
+    }
+
+    @Test
+    public void testOnlyTupleSlotColumnsAreProjected() {
+        // THE pruning guarantee: a 3-column table queried for 1 column 
projects exactly that 1 handle.
+        // MUTATION: returning allHandles.values() (or dropping the slot loop) 
makes this return 3 -> red,
+        // and downstream would make the jdbc connector emit all three columns 
in its remote SELECT.
+        Map<String, ConnectorColumnHandle> all = handles("c1", "c2", "c3");
+        PluginDrivenScanNode node = nodeWith(all, slotFor("c2"));
+
+        List<ConnectorColumnHandle> selected = Deencapsulation.invoke(node, 
"buildColumnHandles");
+
+        Assertions.assertEquals(1, selected.size(),
+                "only the queried column may be projected, got: " + selected);
+        Assertions.assertSame(all.get("c2"), selected.get(0));
+    }
+
+    @Test
+    public void testProjectionOrderFollowsSlotOrderNotHandleOrder() {
+        // The connector renders this list positionally (the jdbc SELECT list 
order IS the order BE maps
+        // result columns back onto the scan's slots), so the order must come 
from the tuple slots, not from
+        // the connector's handle map. MUTATION: iterating allHandles instead 
of the slots yields [c1, c3].
+        Map<String, ConnectorColumnHandle> all = handles("c1", "c2", "c3");
+        PluginDrivenScanNode node = nodeWith(all, slotFor("c3"), 
slotFor("c1"));
+
+        List<ConnectorColumnHandle> selected = Deencapsulation.invoke(node, 
"buildColumnHandles");
+
+        Assertions.assertEquals(2, selected.size());
+        Assertions.assertSame(all.get("c3"), selected.get(0), "slot order must 
win over handle-map order");
+        Assertions.assertSame(all.get("c1"), selected.get(1));
+    }
+
+    @Test
+    public void testSlotWithoutColumnIsSkipped() {
+        // A slot with no backing column has no name to look up; projecting it 
would need a handle that
+        // cannot exist. Pins the `slot.getColumn() != null` guard against an 
NPE regression.
+        Map<String, ConnectorColumnHandle> all = handles("c1", "c2");
+        PluginDrivenScanNode node = nodeWith(all, slotWithoutColumn(), 
slotFor("c1"));
+
+        List<ConnectorColumnHandle> selected = Deencapsulation.invoke(node, 
"buildColumnHandles");
+
+        Assertions.assertEquals(1, selected.size());
+        Assertions.assertSame(all.get("c1"), selected.get(0));
+    }
+
+    @Test
+    public void testSlotWithNoMatchingHandleIsDropped() {
+        // Without a pinned time-travel schema the drop is deliberately silent 
(the fail-loud path is gated
+        // on supportsColumnHandleSnapshotPin) — pinned here so a future 
change to that gate is a conscious
+        // one, and so the unmatched slot can never leak a null into the 
projected list.
+        Map<String, ConnectorColumnHandle> all = handles("c1");
+        PluginDrivenScanNode node = nodeWith(all, slotFor("c1"), 
slotFor("gone"));
+
+        List<ConnectorColumnHandle> selected = Deencapsulation.invoke(node, 
"buildColumnHandles");
+
+        Assertions.assertEquals(1, selected.size());
+        Assertions.assertSame(all.get("c1"), selected.get(0));
+    }
+
+    @Test
+    public void testEmptyTupleProjectsNothing() {
+        // A tuple with no slots projects nothing — the ONLY input that makes 
the jdbc connector fall back to
+        // `SELECT *`. This is what a `count(*)` scan would look like if the 
engine's keep-the-smallest-column
+        // fallback (ColumnPruning / 
PhysicalPlanTranslator.updateScanSlotsMaterialization) ever stopped
+        // firing, so pinning it keeps that fallback reachable ONLY from an 
empty tuple, never from a
+        // pruning bug that happens to drop every slot.
+        PluginDrivenScanNode node = nodeWith(handles("c1", "c2", "c3"));
+
+        List<ConnectorColumnHandle> selected = Deencapsulation.invoke(node, 
"buildColumnHandles");
+
+        Assertions.assertTrue(selected.isEmpty(), "an empty tuple must project 
nothing, got: " + selected);
+    }
+}
diff --git 
a/regression-test/suites/external_table_p0/jdbc/test_mysql_jdbc_catalog.groovy 
b/regression-test/suites/external_table_p0/jdbc/test_mysql_jdbc_catalog.groovy
index 8374ca68b09..af15c2f3c42 100644
--- 
a/regression-test/suites/external_table_p0/jdbc/test_mysql_jdbc_catalog.groovy
+++ 
b/regression-test/suites/external_table_p0/jdbc/test_mysql_jdbc_catalog.groovy
@@ -383,6 +383,35 @@ suite("test_mysql_jdbc_catalog", "p0,external") {
 
             contains "QUERY: SELECT `k12` FROM `doris_test`.`test1`"
         }
+        // Same projection, no WHERE: the remote query must still carry only 
the projected column.
+        // Every other remote-query assertion here filters, and a filter is 
exactly what used to make the
+        // connector rebuild its scan properties -- those are first built 
during scan init(), which runs
+        // before the project above the scan prunes the tuple, so a WHERE-less 
query explained (and told
+        // the connector) a full-width read. Keep one assertion on the 
unfiltered path.
+        explain {
+            sql("select k8 from test1;")
+
+            contains "QUERY: SELECT `k8` FROM `doris_test`.`test1`"
+        }
+        // count(*) asks for no column of its own, but the remote scan still 
has to read something.
+        // PhysicalPlanTranslator.updateScanSlotsMaterialization keeps exactly 
ONE (smallest) slot rather
+        // than letting the tuple go empty, and an empty projection is 
precisely what makes JdbcQueryBuilder
+        // emit `SELECT *` -- i.e. a full 12-column read of test1 just to 
count rows. That regression is
+        // invisible to every result-comparing test, so it needs an explain 
assertion. Asserted on the arity
+        // of the select list, not on which column wins: that is 
getSmallestSlot's business and may
+        // legitimately change with type widths.
+        explain {
+            sql("select count(*) from test1;")
+            check { String explainStr ->
+                def matcher = (explainStr =~ /QUERY: SELECT (.*) FROM 
`doris_test`\.`test1`/)
+                assertTrue(matcher.find(), "no jdbc remote QUERY for test1 in 
explain:\n${explainStr}")
+                def selectList = matcher.group(1).trim()
+                assertTrue(selectList.startsWith("`"),
+                        "count(*) must not degrade to a full-width read, got 
select list: ${selectList}")
+                assertEquals(1, selectList.split(",").size(),
+                        "count(*) must read exactly one column, got select 
list: ${selectList}")
+            }
+        }
         explain {
             sql ("SELECT timestamp0  from dt where 
DATE_TRUNC(date_sub(timestamp0,INTERVAL 9 HOUR),'hour') > '2011-03-03 
17:39:05';")
 


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

Reply via email to