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 6d7992f5336 [fix](paimon connector) Five independent fixes a
sibling-connector read depends on (#66403)
6d7992f5336 is described below
commit 6d7992f533697f597a64e14ec2fc355464ba21be
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Aug 4 11:45:10 2026 +0800
[fix](paimon connector) Five independent fixes a sibling-connector read
depends on (#66403)
### What problem does this PR solve?
Issue Number: close #xxx
Related PR: #66399
Problem Summary:
Five independent fixes, none of them in one connector's own code. They
were found while building the fluss catalog (#66399), which is where
each one's symptom first showed up — but every one of them is a Doris
bug or a Doris gap that exists without fluss, so they are proposed on
their own, ahead of and separately from that connector. **#66399 will be
rebased on top of this and shrink by exactly these five commits.**
They are unrelated to each other; there is one commit per fix and each
can be reviewed alone.
---
#### 1. `[fix](be) Stop exporting the statically linked RocksDB symbols`
`be/src/service/CMakeLists.txt` — one line, plus why.
`doris_be` sets `ENABLE_EXPORTS`, so the 4840 rocksdb symbols it links
statically are exported into the global dynamic symbol table. The
executable is the highest-priority definition for everything loaded
after it, so **any** JNI library that carries its own RocksDB has its
internal calls resolved into doris_be's copy instead — 2576 symbols with
byte-identical mangled names.
That would be survivable if the two agreed on layout. They do not: such
libraries are commonly built against the pre-C++11 libstdc++ string ABI
(`...C1ERKSs`) while doris_be is built against the new one
(`...RKNSt7__cxx1112basic_stringE`). An object constructed with one
layout and used by functions compiled for the other yields a garbage
length, an `std::bad_alloc` that escapes through the JNI frame, and an
aborted BE process.
The fix hides that one archive from the dynamic symbol table, so such a
library binds to its own copy. It is scoped to the archive rather than
dropping `ENABLE_EXPORTS`, because what actually needs the exports is
native UDFs (`runtime/user_function_cache.cpp` dlopens them) and those
use the Doris UDF ABI, which has nothing to do with RocksDB. Crash
stacks do not need it either — they are symbolized from debug info,
which is why they can name even anonymous-namespace functions.
Verified by symbol table rather than by argument: after the change 61
rocksdb symbols remain exported (compiler-instantiated inline/template
members that landed in Doris's own objects, which an archive-level
exclusion cannot reach). 29 of those share a name with a JNI library's,
but `readelf -r` shows **none** of them in that library's relocation
table, so it never looks them up. The zstd/lz4/snappy/bzip2/zlib
duplicates are left alone deliberately: those are C ABIs, stable and
layout-free, unlike RocksDB's C++ objects.
⚠️ This changes BE's link behaviour, so it wants a full relink and a BE
regression run — tablet metadata itself lives in RocksDB.
#### 2. `[fix](be) Pick the table reader per scan range, not per scan
node`
`be/src/exec/scan/file_scanner_v2.{h,cpp}` + unit test.
`_open_impl` builds one `_table_reader` from the **first** scan range;
`_prepare_next_split` then reuses it for every range that follows, and
never revisits the choice. The reader is format-specific, so a scan node
holding ranges of two different `table_format_type`s hands the second
kind to the first kind's reader.
That does not fail cleanly. It fails as whatever the wrong reader makes
of a foreign range — e.g. paimon's reader reporting an unsupported file
format for a range that carries no paimon parameters at all. And which
ranges end up in the same scanner is the engine's assignment, so **the
same query succeeds or fails depending on how the ranges happened to be
dealt out**, and changing the projection can change the outcome.
The fix records the format the reader was built for and rebuilds when a
range disagrees. The expression contexts are deliberately *not* rebuilt:
they are per-scanner and format-independent, and `_init_expr_ctxes` is
not idempotent.
A scan node mixing formats is what a connector reading a table as "a
lake plus the log written after it" produces — its lake half planned by
a sibling connector, its own half by itself — but nothing in the scanner
assumes that, and the fix is a general one.
New unit test `TheTableReaderIsRebuiltWhenARangeChangesTableFormat`:
same format reuses the reader, a different format replaces it, and the
formats really do map to different reader types (otherwise the first two
assertions would hold for a scanner that never rebuilt anything).
Reverting the comparison to the pre-fix behaviour turns it red.
#### 3. `[fix](paimon) Claim the table handles this connector produces`
`fe/fe-connector/fe-connector-paimon` + unit tests.
`Connector.ownsHandle` defaults to `false`. The iceberg and hudi
connectors override it — they are already used as siblings behind the
hms gateway — but paimon never did. Any gateway connector that embeds
paimon therefore asks "is this handle yours?" about a handle paimon
itself produced and is told no, so every one of the gateway's type
guards fails open and the first cast throws `ClassCastException`.
One method, same implementation as the two siblings that already have
it.
#### 4. `[feat](paimon) Say which bucket a scan range came from`
`fe/fe-connector/fe-connector-paimon` + unit tests.
Adds `paimon.bucket` = `DataSplit.bucket()` to the scan range
properties, so a connector that plans paimon splits on behalf of its own
table can line them up with its own per-bucket state.
FE-only: `populateRangeParams` does not forward it, so **BE is
unaffected**. Set on every `DataSplit`-backed range, native and JNI
alike, so which reader BE ends up using cannot change what a caller can
learn about the split. Deliberately **not** set on the collapsed
`COUNT(*)` range (it stands for splits from several buckets, so any
single number would be a lie) nor on a non-`DataSplit` system split
(there is no bucket). Consumers are expected to fail loud when it is
absent on a range they meant to bind, since treating that as "no state
for this bucket" is a wrong-results bug rather than a degradation.
#### 5. `[feat](connector) Let a connector name the columns its reader
must read`
`fe/fe-connector/fe-connector-api` + `fe/fe-core` + unit tests. **The
only engine-side change here.**
A connector whose BE-side reader merges, suppresses or otherwise
identifies rows by key needs those key columns to be READ, whether or
not the query selected them. Today the plugin scan's tuple is pruned to
the projection, so the reader is handed a scan without the column it
needs.
**This is not a new mechanism.** Doris does exactly this for its own
aggregate and merge-on-read unique-key tables:
`PhysicalPlanTranslator.preserveExtraStorageKeySlots` keeps the key
slots and ships them as `extra_key_column_slot_ids`, because BE merges
by key regardless of what was selected. The new branch sits beside that
one, before the same `removeIf`, and only widens the scan's tuple — the
project above it was already given its own output tuple, so a preserved
column is read and then dropped and never reaches the query's output.
Three names:
- SPI `ConnectorScanPlanProvider.getMustReadColumns(session, handle)` —
**defaults to an empty set**, so every existing connector prunes exactly
as before
- `PluginDrivenScanNode.mustReadColumnsFromConnector()` — same memoized
provider the rest of planning uses, with the plugin classloader pinned
- `PhysicalPlanTranslator.preserveConnectorMustReadSlots()`
A returned name that matches no slot fails the query loud rather than
being skipped: it means the connector and the engine disagree about the
table, and reading on would hand the connector's reader a scan missing a
column it said it needs — silently wrong rows, not an error.
---
be/src/exec/scan/file_scanner_v2.cpp | 34 +++
be/src/exec/scan/file_scanner_v2.h | 7 +
be/src/service/CMakeLists.txt | 18 ++
be/test/exec/scan/file_scanner_v2_test.cpp | 54 ++++
.../api/scan/ConnectorScanPlanProvider.java | 37 +++
...nnectorScanPlanProviderMustReadColumnsTest.java | 79 ++++++
.../doris/connector/paimon/PaimonConnector.java | 17 ++
.../connector/paimon/PaimonScanPlanProvider.java | 25 +-
.../doris/connector/paimon/PaimonScanRange.java | 21 ++
.../paimon/PaimonConnectorOwnsHandleTest.java | 78 ++++++
.../paimon/PaimonScanPlanProviderTest.java | 20 +-
.../paimon/PaimonScanRangeBucketTest.java | 296 +++++++++++++++++++++
.../datasource/scan/PluginDrivenScanNode.java | 23 ++
.../glue/translator/PhysicalPlanTranslator.java | 39 +++
.../PluginDrivenScanNodeMustReadColumnsTest.java | 129 +++++++++
.../PhysicalPlanTranslatorMustReadSlotsTest.java | 169 ++++++++++++
16 files changed, 1028 insertions(+), 18 deletions(-)
diff --git a/be/src/exec/scan/file_scanner_v2.cpp
b/be/src/exec/scan/file_scanner_v2.cpp
index b7bf895baba..ad7ffc34829 100644
--- a/be/src/exec/scan/file_scanner_v2.cpp
+++ b/be/src/exec/scan/file_scanner_v2.cpp
@@ -403,6 +403,7 @@ Status FileScannerV2::_open_impl(RuntimeState* state) {
if (_first_scan_range) {
RETURN_IF_ERROR(_create_table_reader_for_format(_current_range,
&_table_reader));
DORIS_CHECK(_table_reader != nullptr);
+ _table_reader_format = table_format_name(_current_range);
RETURN_IF_ERROR(_init_expr_ctxes());
RETURN_IF_ERROR(_init_table_reader(_current_range));
}
@@ -508,6 +509,14 @@ Status FileScannerV2::_prepare_next_split(bool* eos) {
DORIS_CHECK(_table_reader != nullptr);
_current_range_path = _current_range.path;
+ bool reader_rebuilt = false;
+
RETURN_IF_ERROR(_rebuild_table_reader_if_format_changed(_current_range,
&reader_rebuilt));
+ if (reader_rebuilt) {
+ // Same init the first reader got. The expression contexts are NOT
rebuilt: they are
+ // per-scanner and format-independent, and _init_expr_ctxes is not
idempotent.
+ RETURN_IF_ERROR(_init_table_reader(_current_range));
+ }
+
const auto format_type = get_range_format_type(*_params,
_current_range);
_init_adaptive_batch_size_state(format_type);
if (_block_size_predictor != nullptr) {
@@ -590,6 +599,31 @@ Status FileScannerV2::_init_table_reader(const
TFileRangeDesc& range) {
return Status::OK();
}
+Status FileScannerV2::_rebuild_table_reader_if_format_changed(const
TFileRangeDesc& range,
+ bool* rebuilt) {
+ // The reader is chosen by the range's table format, not the node's,
because one node can be given
+ // both: a connector that reads a table as a lake plus the log written
after it plans its lake half
+ // through a sibling connector and its log half itself, and both land here
as ranges of the same
+ // scan. Built once from the first range and never revisited, the reader
is then handed a range of
+ // the other format -- which does not fail cleanly. It fails as whatever
that reader makes of a
+ // foreign range, e.g. paimon's reporting an unsupported file format for a
range that carries no
+ // paimon parameters at all. And which ranges share a scanner is up to the
engine's assignment, so
+ // the same query succeeds or fails by how the ranges happened to be dealt
out.
+ //
+ // Split out from _prepare_next_split so the decision can be tested on its
own: re-initializing the
+ // new reader needs scan-wide state that choosing it does not, so that
step stays with the caller.
+ auto table_format = table_format_name(range);
+ if (table_format == _table_reader_format) {
+ *rebuilt = false;
+ return Status::OK();
+ }
+ RETURN_IF_ERROR(_create_table_reader_for_format(range, &_table_reader));
+ DORIS_CHECK(_table_reader != nullptr);
+ _table_reader_format = std::move(table_format);
+ *rebuilt = true;
+ return Status::OK();
+}
+
Status FileScannerV2::_create_table_reader_for_format(
const TFileRangeDesc& range, std::unique_ptr<format::TableReader>*
reader) const {
DORIS_CHECK(reader != nullptr);
diff --git a/be/src/exec/scan/file_scanner_v2.h
b/be/src/exec/scan/file_scanner_v2.h
index f7141fa3919..03e5f4d6bbc 100644
--- a/be/src/exec/scan/file_scanner_v2.h
+++ b/be/src/exec/scan/file_scanner_v2.h
@@ -129,6 +129,9 @@ private:
Status _init_table_reader(const TFileRangeDesc& range);
Status _create_table_reader_for_format(const TFileRangeDesc& range,
std::unique_ptr<format::TableReader>* reader) const;
+ // Replaces _table_reader when {@code range} carries a different table
format than the one it was
+ // built for, reporting whether it did. See the definition for why the
reader follows the range.
+ Status _rebuild_table_reader_if_format_changed(const TFileRangeDesc&
range, bool* rebuilt);
Status _prepare_table_reader_split(const TFileRangeDesc& range,
std::map<std::string, Field>
partition_values);
static bool _should_skip_not_found(const Status& status, bool
ignore_not_found);
@@ -182,6 +185,10 @@ private:
std::string _current_range_path;
std::unique_ptr<format::TableReader> _table_reader;
+ // The table format _table_reader was built for. A scan node may mix table
formats -- a fluss
+ // union read gives one node its lake half as paimon ranges and its log
half as fluss ones -- and
+ // the reader is format-specific, so it is rebuilt whenever this stops
matching the range.
+ std::string _table_reader_format;
std::vector<format::ColumnDefinition> _projected_columns;
// File formats without embedded schema, such as CSV, still need the FE
slot descriptors in
// file-column order. This mirrors old FileScanner::_file_slot_descs and
is passed only to
diff --git a/be/src/service/CMakeLists.txt b/be/src/service/CMakeLists.txt
index b9bcef0b7b8..64ed8651417 100644
--- a/be/src/service/CMakeLists.txt
+++ b/be/src/service/CMakeLists.txt
@@ -49,6 +49,24 @@ if (${MAKE_TEST} STREQUAL "OFF" AND ${BUILD_BENCHMARK}
STREQUAL "OFF")
# This permits libraries loaded by dlopen to link to the symbols in the
program.
set_target_properties(doris_be PROPERTIES ENABLE_EXPORTS 1)
+ # ...but not the symbols of the RocksDB we link statically. Exporting
those makes this
+ # executable the definition every later-loaded library binds to, and a JNI
library that
+ # carries its own RocksDB then runs half on ours: the fluss scanner
bundles frocksdbjni,
+ # whose librocksdbjni.so defines 2576 rocksdb symbols under names
identical to ours but
+ # was built against the pre-C++11 libstdc++ string ABI. Objects laid out
by one and used
+ # by the other yield a garbage length, an std::bad_alloc that escapes the
JNI frame, and
+ # an aborted BE. Hiding this archive lets that library bind to its own
copy.
+ #
+ # Scoped to the archive rather than dropping ENABLE_EXPORTS: what needs
the exports is
+ # native UDFs (runtime/user_function_cache.cpp dlopens them), and those
use the Doris UDF
+ # ABI, which has nothing to do with RocksDB. Crash stacks do not need it
either -- they are
+ # symbolized from debug info, which is why they name even
anonymous-namespace functions.
+ #
+ # The same library also duplicates zstd, lz4, snappy, bzip2 and zlib
symbols. Those are C
+ # ABIs, stable across versions and layout-free, so they are left alone
until something
+ # shows otherwise -- unlike RocksDB, whose C++ objects are what actually
corrupt.
+ target_link_options(doris_be PRIVATE "-Wl,--exclude-libs,librocksdb.a")
+
target_link_libraries(doris_be
${DORIS_LINK_LIBS}
)
diff --git a/be/test/exec/scan/file_scanner_v2_test.cpp
b/be/test/exec/scan/file_scanner_v2_test.cpp
index 353c08043ad..3506e5db27a 100644
--- a/be/test/exec/scan/file_scanner_v2_test.cpp
+++ b/be/test/exec/scan/file_scanner_v2_test.cpp
@@ -475,6 +475,60 @@ TEST(FileScannerV2Test,
JniCompatibilityShapesUseV2Scanner) {
EXPECT_TRUE(FileScannerV2::is_supported(params,
legacy_paimon_jni_range_without_reader_type()));
}
+// Scenario: one scan node is given ranges of two different table formats,
which is what a connector
+// reading a table as a lake plus the log written after it produces -- its
lake half planned by a
+// sibling connector, its own half by itself. The reader is format-specific,
so it has to follow the
+// RANGE. Built once from the first range, it is later handed a foreign one
and fails as whatever that
+// reader makes of it, not as a clean error; and since which ranges share a
scanner is the engine's
+// assignment, the same query then succeeds or fails by how the ranges
happened to be dealt out.
+TEST(FileScannerV2Test, TheTableReaderIsRebuiltWhenARangeChangesTableFormat) {
+ RuntimeState state {TQueryOptions(), TQueryGlobals()};
+ RuntimeProfile profile("file_scanner_v2_reader_per_range");
+ TFileScanRangeParams params;
+ params.__set_format_type(TFileFormatType::FORMAT_PARQUET);
+
+ FileScannerV2 scanner(&state, &profile, nullptr);
+ scanner._params = ¶ms;
+
+ const auto paimon_range = range_with_format("paimon",
TFileFormatType::FORMAT_PARQUET);
+ const auto hive_range = range_with_format("hive",
TFileFormatType::FORMAT_PARQUET);
+
+ // Nothing has been built yet, so the first range always builds.
+ bool rebuilt = false;
+ ASSERT_TRUE(scanner._rebuild_table_reader_if_format_changed(paimon_range,
&rebuilt).ok());
+ EXPECT_TRUE(rebuilt);
+ EXPECT_EQ(scanner._table_reader_format, "paimon");
+ const auto* first_reader = scanner._table_reader.get();
+ ASSERT_NE(first_reader, nullptr);
+
+ // A second range of the same format reuses it. Rebuilding here would be
wasteful rather than
+ // wrong, but it would also throw away per-reader state the next split
expects to still be there.
+ ASSERT_TRUE(scanner._rebuild_table_reader_if_format_changed(paimon_range,
&rebuilt).ok());
+ EXPECT_FALSE(rebuilt);
+ EXPECT_EQ(scanner._table_reader.get(), first_reader);
+
+ // A range of another format must not be handed to the reader built for
the first one.
+ ASSERT_TRUE(scanner._rebuild_table_reader_if_format_changed(hive_range,
&rebuilt).ok());
+ EXPECT_TRUE(rebuilt);
+ EXPECT_EQ(scanner._table_reader_format, "hive");
+ EXPECT_NE(scanner._table_reader.get(), first_reader);
+
+ // And back again, because the ranges of a mixed node arrive interleaved
rather than grouped.
+ ASSERT_TRUE(scanner._rebuild_table_reader_if_format_changed(paimon_range,
&rebuilt).ok());
+ EXPECT_TRUE(rebuilt);
+ EXPECT_EQ(scanner._table_reader_format, "paimon");
+
+ // The formats really do get different readers -- otherwise every
assertion above would hold
+ // just as well for a scanner that never rebuilt anything.
+ std::unique_ptr<format::TableReader> as_paimon;
+ std::unique_ptr<format::TableReader> as_hive;
+ ASSERT_TRUE(scanner._create_table_reader_for_format(paimon_range,
&as_paimon).ok());
+ ASSERT_TRUE(scanner._create_table_reader_for_format(hive_range,
&as_hive).ok());
+ const format::TableReader& paimon_reader = *as_paimon;
+ const format::TableReader& hive_reader = *as_hive;
+ EXPECT_STRNE(typeid(paimon_reader).name(), typeid(hive_reader).name());
+}
+
TEST(FileScannerV2Test, FailedTableReaderCloseCanBeRetriedThroughScanner) {
RuntimeState state {TQueryOptions(), TQueryGlobals()};
RuntimeProfile profile("file_scanner_v2_close_retry");
diff --git
a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java
b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java
index acd44d5dfc9..a13af026a75 100644
---
a/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java
+++
b/fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java
@@ -30,6 +30,7 @@ import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
+import java.util.Set;
/**
* Plans the set of scan ranges (splits) needed to read a connector table.
@@ -148,6 +149,42 @@ public interface ConnectorScanPlanProvider {
return inferred;
}
+ /**
+ * The columns BE must READ for this scan even when the query references
none of them, by Doris-side
+ * column name. The engine keeps their slots in the scan's tuple instead
of pruning them away; the
+ * projection above the scan still removes them from the query's output,
so the answer changes what is
+ * read, never what is returned.
+ *
+ * <p>This exists for a connector whose BE-side reader needs a column to
produce CORRECT ROWS rather than
+ * to answer the query — a merge key, a suppression key, a row identity.
Doris does the same thing for its
+ * own aggregate / merge-on-read unique-key tables ({@code
PhysicalPlanTranslator.preserveExtraStorageKeySlots}):
+ * BE merges by key whether or not the user selected the key. Trino has no
counterpart because its
+ * connectors own the page source and can add such columns privately; here
the reader is BE, so the columns
+ * have to reach it through the plan.</p>
+ *
+ * <p>Answer per SCAN, not per table: a connector that only sometimes
needs the column (e.g. only when it
+ * decides to combine two sources) must return it only for those scans,
and must reach the SAME decision
+ * when it later plans the splits — the engine asks this during plan
translation, strictly before
+ * {@link #planScan}. Memoize that decision on the provider instance (the
engine keeps one per scan node)
+ * rather than deciding twice: two independent decisions can disagree, and
then BE is asked to read a
+ * column the tuple does not carry.</p>
+ *
+ * <p>Every name returned must be a column of the scanned table, spelled
as Doris knows it (the same
+ * identifier-mapped name {@link #classifyColumn} receives). A name that
matches no slot in the scan's
+ * tuple fails the query loud: it means the connector and the engine
disagree about the table, and reading
+ * on would silently produce whatever the connector's reader does without
that column.</p>
+ *
+ * <p>The default returns an empty set — every connector whose reader
needs nothing beyond the projection
+ * is untouched, and its scans prune exactly as before.</p>
+ *
+ * @param session the current session
+ * @param handle the table handle being scanned
+ * @return Doris-side names of the columns to read regardless of the
projection (default: empty)
+ */
+ default Set<String> getMustReadColumns(ConnectorSession session,
ConnectorTableHandle handle) {
+ return Collections.emptySet();
+ }
+
/**
* Plans the scan described by {@code request}, returning the ranges that
cover the requested data.
*
diff --git
a/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderMustReadColumnsTest.java
b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderMustReadColumnsTest.java
new file mode 100644
index 00000000000..f5e6cdb1d8d
--- /dev/null
+++
b/fe/fe-connector/fe-connector-api/src/test/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProviderMustReadColumnsTest.java
@@ -0,0 +1,79 @@
+// 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.connector.api.scan;
+
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.handle.ConnectorTableHandle;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Guards the additive {@code getMustReadColumns} SPI default on {@link
ConnectorScanPlanProvider}.
+ *
+ * <p>WHY: the engine consults this on EVERY plugin-table scan that has a
projection above it, and widens the
+ * scan's tuple by whatever comes back. The default must therefore be empty,
or every connector that never
+ * asked for anything would start reading extra columns — and, worse, would
fail the query loud when a name
+ * it never returned matches no slot. This is the zero-break guard for
es/jdbc/paimon/iceberg/hive/maxcompute,
+ * none of which override it.</p>
+ */
+public class ConnectorScanPlanProviderMustReadColumnsTest {
+
+ /** Bare provider: only the abstract planScan implemented; everything else
inherits SPI defaults. */
+ private static final class BareProvider implements
ConnectorScanPlanProvider {
+ @Override
+ public List<ConnectorScanRange> planScan(ConnectorSession session,
ConnectorScanRequest request) {
+ return Collections.emptyList();
+ }
+ }
+
+ /** A connector whose BE-side reader needs a merge key the query may not
have selected. */
+ private static final class KeyReadingProvider implements
ConnectorScanPlanProvider {
+ @Override
+ public List<ConnectorScanRange> planScan(ConnectorSession session,
ConnectorScanRequest request) {
+ return Collections.emptyList();
+ }
+
+ @Override
+ public Set<String> getMustReadColumns(ConnectorSession session,
ConnectorTableHandle handle) {
+ return Collections.singleton("id");
+ }
+ }
+
+ @Test
+ public void defaultAsksForNoExtraColumns() {
+ ConnectorScanPlanProvider provider = new BareProvider();
+
+ // MUTATION: a default returning anything non-empty would widen every
connector's scans and fail
+ // loud on the first name that matches no slot -> red here first.
+ Assertions.assertEquals(Collections.emptySet(),
provider.getMustReadColumns(null, null),
+ "a connector that never opted in must ask for no extra
columns");
+ }
+
+ @Test
+ public void connectorThatOptsInIsObeyed() {
+ ConnectorScanPlanProvider provider = new KeyReadingProvider();
+
+ Assertions.assertEquals(Collections.singleton("id"),
provider.getMustReadColumns(null, null),
+ "the engine must read back exactly what the connector asked
for");
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
index 79f15eae474..34990f28fc2 100644
---
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
+++
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java
@@ -23,6 +23,7 @@ import org.apache.doris.connector.api.ConnectorMetadata;
import org.apache.doris.connector.api.ConnectorPartitionInfo;
import org.apache.doris.connector.api.ConnectorSession;
import org.apache.doris.connector.api.ConnectorValidationContext;
+import org.apache.doris.connector.api.handle.ConnectorTableHandle;
import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider;
import org.apache.doris.connector.cache.ConnectorMetadataCache;
import org.apache.doris.connector.metastore.HmsMetaStoreProperties;
@@ -254,6 +255,22 @@ public class PaimonConnector implements Connector {
properties, context, schemaAtMemo, latestSnapshotCache,
partitionViewCache);
}
+ /**
+ * True for a handle this connector produced (a {@link
PaimonTableHandle}). Tested against this connector's
+ * OWN in-loader type, so a gateway connector that embeds this one as a
sibling can route a foreign paimon
+ * handle here without casting it across the plugin classloader split.
Returns false for any other
+ * connector's handle, so the gateway keeps looking.
+ *
+ * <p>The default is {@code false}, which for a sibling means every one of
the gateway's guards silently
+ * fails open and the first cast throws a ClassCastException instead — so
this is required of any connector
+ * used as a sibling, not an optimization. Same implementation as the
iceberg and hudi siblings behind the
+ * hms gateway.
+ */
+ @Override
+ public boolean ownsHandle(ConnectorTableHandle handle) {
+ return handle instanceof PaimonTableHandle;
+ }
+
@Override
public void invalidateTable(String dbName, String tableName) {
// REFRESH TABLE (and, via the generic PluginDrivenExternalCatalog DDL
hook, a Doris-issued
diff --git
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
index ef5e4650d88..3f6213ec339 100644
---
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
+++
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanPlanProvider.java
@@ -761,7 +761,8 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
(optDeletionFiles.isPresent() && i <
optDeletionFiles.get().size())
? optDeletionFiles.get().get(i) : null;
ranges.addAll(buildNativeRanges(file, deletionFile,
defaultFileFormat,
- partitionValues, vendedToken, effectiveSplitSize,
weightDenominator));
+ partitionValues, vendedToken, effectiveSplitSize,
weightDenominator,
+ dataSplit.bucket()));
}
} else {
// JNI reader path
@@ -798,7 +799,8 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
*/
PaimonScanRange buildNativeRange(RawFile file, DeletionFile deletionFile,
String defaultFileFormat, Map<String, String> partitionValues,
- Map<String, String> vendedToken, long start, long length, long
weightDenominator) {
+ Map<String, String> vendedToken, long start, long length, long
weightDenominator,
+ int bucket) {
String fileFormat =
getFileFormatBySuffix(file.path()).orElse(defaultFileFormat);
// FIX-A1: native sub-split FE weight = the sub-range byte length, +
the deletion-vector length when
// attached (legacy PaimonSplit(LocationPath,...).selfSplitWeight =
length, setDeletionFile += DV).
@@ -814,7 +816,8 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
.partitionValues(partitionValues)
.selfSplitWeight(selfSplitWeight)
.targetSplitSize(weightDenominator)
- .schemaId(file.schemaId());
+ .schemaId(file.schemaId())
+ .bucket(bucket);
if (deletionFile != null) {
builder.deletionFile(
normalizeUri(deletionFile.path(), vendedToken),
@@ -836,11 +839,12 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
*/
List<PaimonScanRange> buildNativeRanges(RawFile file, DeletionFile
deletionFile,
String defaultFileFormat, Map<String, String> partitionValues,
- Map<String, String> vendedToken, long targetSplitSize, long
weightDenominator) {
+ Map<String, String> vendedToken, long targetSplitSize, long
weightDenominator,
+ int bucket) {
List<PaimonScanRange> result = new ArrayList<>();
for (long[] offset : computeFileSplitOffsets(file.length(),
targetSplitSize)) {
result.add(buildNativeRange(file, deletionFile, defaultFileFormat,
- partitionValues, vendedToken, offset[0], offset[1],
weightDenominator));
+ partitionValues, vendedToken, offset[0], offset[1],
weightDenominator, bucket));
}
return result;
}
@@ -1372,13 +1376,18 @@ public class PaimonScanPlanProvider implements
ConnectorScanPlanProvider {
String fileFormat = isDataSplit
? dataSplitFileFormat((DataSplit) split, defaultFileFormat)
: defaultFileFormat;
- return new PaimonScanRange.Builder()
+ PaimonScanRange.Builder builder = new PaimonScanRange.Builder()
.fileFormat(fileFormat)
.paimonSplit(serializedSplit)
.partitionValues(partitionValues)
.selfSplitWeight(splitWeight)
- .targetSplitSize(weightDenominator)
- .build();
+ .targetSplitSize(weightDenominator);
+ if (isDataSplit) {
+ // Same bucket property as the native arm: which reader BE ends up
using must not change
+ // what a sibling connector can learn about the split (see
PaimonScanRange's props).
+ builder.bucket(((DataSplit) split).bucket());
+ }
+ return builder.build();
}
/**
diff --git
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanRange.java
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanRange.java
index e6097aae4f5..2a237762115 100644
---
a/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanRange.java
+++
b/fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonScanRange.java
@@ -91,6 +91,19 @@ public class PaimonScanRange implements ConnectorScanRange {
if (builder.rowCount != null) {
props.put("paimon.row_count", String.valueOf(builder.rowCount));
}
+ // FE-ONLY (never reaches BE, see populateRangeParams): the paimon
bucket this range's data
+ // belongs to, = DataSplit.bucket(). Read by a sibling connector that
plans paimon splits on
+ // behalf of its own table and has to line them up with its own
per-bucket state — today the
+ // fluss connector, whose lake half is planned here (it binds a fluss
log tail to the lake
+ // splits of the SAME bucket; a lake table tiered from fluss has
bucket-identical layout).
+ // Set on every DataSplit-backed range, native and JNI alike. NOT set
on the collapsed
+ // COUNT(*) range (it stands for splits from many buckets, so any
single number would be a
+ // lie) nor on a non-DataSplit system split (no bucket exists).
Consumers must fail loud when
+ // it is absent on a range they expected to bind — silently treating
that as "no state for
+ // this bucket" is a wrong-results bug, not a degradation.
+ if (builder.bucket != null) {
+ props.put("paimon.bucket", String.valueOf(builder.bucket));
+ }
// FIX-A3: emit the self-split-weight for every JNI split, incl.
weight 0. Legacy
// PaimonScanNode.setPaimonParams:274 sets it unconditionally on the
JNI branch (never on
// native); the old `selfSplitWeight > 0` gate was a buggy is-set
proxy that dropped a genuine
@@ -307,6 +320,9 @@ public class PaimonScanRange implements ConnectorScanRange {
// COUNT pushdown
private Long rowCount;
+ // Bucket of the backing DataSplit; null for splits that have none
(see the props comment).
+ private Integer bucket;
+
public Builder path(String path) {
this.path = path;
return this;
@@ -369,6 +385,11 @@ public class PaimonScanRange implements ConnectorScanRange
{
return this;
}
+ public Builder bucket(int bucket) {
+ this.bucket = bucket;
+ return this;
+ }
+
public PaimonScanRange build() {
return new PaimonScanRange(this);
}
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorOwnsHandleTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorOwnsHandleTest.java
new file mode 100644
index 00000000000..a3f7955a8e4
--- /dev/null
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonConnectorOwnsHandleTest.java
@@ -0,0 +1,78 @@
+// 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.connector.paimon;
+
+import org.apache.doris.connector.api.handle.ConnectorTableHandle;
+import org.apache.doris.connector.spi.ConnectorContext;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Collections;
+
+/**
+ * Whether this connector claims the handles it produces, which is what lets a
<em>gateway</em> connector
+ * embed it as a sibling: the gateway cannot name this module's handle type
across the plugin classloader
+ * split, so it routes a handle by asking each sibling to test its own
in-loader type.
+ *
+ * <p>Asserted rather than left to the SPI default, because the default is
{@code false} and the failure it
+ * causes points away from here. A sibling that disowns its own handles makes
every guard on the gateway
+ * side fail open, and the first cast throws a ClassCastException naming the
GATEWAY's handle type and two
+ * class loaders — with nothing to suggest that the missing piece is a method
this connector never
+ * overrode. That is not hypothetical: it is what happened the first time the
fluss connector read a lake
+ * table through this one end to end.
+ */
+public class PaimonConnectorOwnsHandleTest {
+
+ @Test
+ public void claimsItsOwnTableHandle() {
+ PaimonConnector connector = new
PaimonConnector(Collections.emptyMap(), context());
+
+ Assertions.assertTrue(connector.ownsHandle(new PaimonTableHandle(
+ "db1", "t1", Collections.emptyList(),
Collections.emptyList())));
+ }
+
+ @Test
+ public void disownsAnotherConnectorsHandle() {
+ // The gateway asks its siblings in turn, so answering yes to a
foreign handle would route it to the
+ // wrong connector instead of leaving the gateway to keep looking.
+ PaimonConnector connector = new
PaimonConnector(Collections.emptyMap(), context());
+
+ Assertions.assertFalse(connector.ownsHandle(new ForeignHandle()));
+ }
+
+ /** Stands in for whatever another connector's handle happens to be; only
its type matters here. */
+ private static final class ForeignHandle implements ConnectorTableHandle {
+ private static final long serialVersionUID = 1L;
+ }
+
+ /** The connector wraps whatever context it is given, so it cannot be
null; nothing here reads it. */
+ private static ConnectorContext context() {
+ return new ConnectorContext() {
+ @Override
+ public String getCatalogName() {
+ return "test_catalog";
+ }
+
+ @Override
+ public long getCatalogId() {
+ return 1L;
+ }
+ };
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
index 1c3dc7f6460..3a1a6442a0b 100644
---
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanPlanProviderTest.java
@@ -351,7 +351,7 @@ public class PaimonScanPlanProviderTest {
"oss://bkt/warehouse/db/t/index/dv-0.index", 8L, 16L, 4L);
PaimonScanRange range = provider.buildNativeRange(
- file, dv, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 100L, 64L * 1024 * 1024);
+ file, dv, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 100L, 64L * 1024 * 1024, 0);
// WHY: BE's scheme-dispatched S3 file factory only opens canonical
s3://. An un-normalized
// oss:// DATA-file path fails the native ORC/Parquet read outright;
an un-normalized oss:// DV
@@ -376,7 +376,7 @@ public class PaimonScanPlanProviderTest {
PaimonScanRange range = provider.buildNativeRange(
parquetRawFile("oss://bkt/a/part-0.parquet"), null, "parquet",
- Collections.emptyMap(), Collections.emptyMap(), 0L, 100L, 64L
* 1024 * 1024);
+ Collections.emptyMap(), Collections.emptyMap(), 0L, 100L, 64L
* 1024 * 1024, 0);
// WHY: a DV-less native split must still normalize its data-file path
and must NOT emit a DV
// descriptor. MUTATION: emitting a deletion_file for a null DV, or
skipping data normalization -> red.
@@ -396,7 +396,7 @@ public class PaimonScanPlanProviderTest {
PaimonScanRange range = provider.buildNativeRange(
parquetRawFile("oss://bkt/a/part-0.parquet"), null, "parquet",
- Collections.emptyMap(), Collections.emptyMap(), 0L, 100L, 64L
* 1024 * 1024);
+ Collections.emptyMap(), Collections.emptyMap(), 0L, 100L, 64L
* 1024 * 1024, 0);
// MUTATION: NPE on null context, or fabricating a normalized path
from nothing -> red.
Assertions.assertEquals("oss://bkt/a/part-0.parquet",
range.getPath().orElse(null));
@@ -421,7 +421,7 @@ public class PaimonScanPlanProviderTest {
"oss://bkt/warehouse/db/t/index/dv-0.index", 8L, 16L, 4L);
PaimonScanRange range = provider.buildNativeRange(
- file, dv, "parquet", Collections.emptyMap(), vendedToken, 0L,
100L, 64L * 1024 * 1024);
+ file, dv, "parquet", Collections.emptyMap(), vendedToken, 0L,
100L, 64L * 1024 * 1024, 0);
// WHY: the engine seam normalizes against the VENDED map (the REST
static map is empty). If the
// connector dropped the token (reverting to the 1-arg seam) or
substituted an empty map, a REST
@@ -1733,7 +1733,7 @@ public class PaimonScanPlanProviderTest {
long target = Math.max(1L, file.length() / 3); // force the file to
sub-split into >=2 ranges
List<PaimonScanRange> ranges = provider.buildNativeRanges(
- file, dv, "parquet", Collections.emptyMap(),
Collections.emptyMap(), target, 64L * 1024 * 1024);
+ file, dv, "parquet", Collections.emptyMap(),
Collections.emptyMap(), target, 64L * 1024 * 1024, 0);
// WHY: the load-bearing correctness claim of FIX-NATIVE-SUBSPLIT — a
paimon deletion vector is a
// bitmap of GLOBAL file row positions, so EVERY sub-range of a
DV-bearing file must carry the
@@ -1761,7 +1761,7 @@ public class PaimonScanPlanProviderTest {
RawFile file = parquetRawFile("oss://bkt/a/part-0.parquet");
List<PaimonScanRange> ranges = provider.buildNativeRanges(
- file, null, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 64L * 1024 * 1024);
+ file, null, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 64L * 1024 * 1024, 0);
Assertions.assertEquals(1, ranges.size(),
"a non-positive target (COUNT(*) pushdown) must keep the file
as one whole-file range");
@@ -2572,14 +2572,14 @@ public class PaimonScanPlanProviderTest {
DeletionFile dv = new DeletionFile("/data/dv-0.index", 8L, 16L, 4L);
PaimonScanRange withDv = provider.buildNativeRange(
- file, dv, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 64L, 64 * MB);
+ file, dv, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 64L, 64 * MB, 0);
Assertions.assertEquals(64L + dv.length(), withDv.getSelfSplitWeight(),
"native weight = sub-range length + the deletion-vector
length");
Assertions.assertEquals(64 * MB, withDv.getTargetSplitSize(),
"native range must carry the weight denominator");
PaimonScanRange noDv = provider.buildNativeRange(
- file, null, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 70L, 64 * MB);
+ file, null, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 70L, 64 * MB, 0);
Assertions.assertEquals(70L, noDv.getSelfSplitWeight(),
"a DV-less native range weight is just the sub-range length");
}
@@ -2597,7 +2597,7 @@ public class PaimonScanPlanProviderTest {
List<PaimonScanRange> ranges = provider.buildNativeRanges(
file, null, "parquet", Collections.emptyMap(),
Collections.emptyMap(),
- fileSplitTarget, denominator);
+ fileSplitTarget, denominator, 0);
Assertions.assertEquals(
PaimonScanPlanProvider.computeFileSplitOffsets(file.length(),
fileSplitTarget).size(),
@@ -2620,7 +2620,7 @@ public class PaimonScanPlanProviderTest {
RawFile file = parquetRawFile("/data/part-0.parquet");
List<PaimonScanRange> ranges = provider.buildNativeRanges(
- file, null, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 64 * MB);
+ file, null, "parquet", Collections.emptyMap(),
Collections.emptyMap(), 0L, 64 * MB, 0);
Assertions.assertEquals(1, ranges.size(), "a non-positive target keeps
the file whole");
Assertions.assertEquals(64 * MB, ranges.get(0).getTargetSplitSize(),
diff --git
a/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeBucketTest.java
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeBucketTest.java
new file mode 100644
index 00000000000..24de90b2ad4
--- /dev/null
+++
b/fe/fe-connector/fe-connector-paimon/src/test/java/org/apache/doris/connector/paimon/PaimonScanRangeBucketTest.java
@@ -0,0 +1,296 @@
+// 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.connector.paimon;
+
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.handle.ConnectorColumnHandle;
+import org.apache.doris.connector.api.scan.ConnectorScanRange;
+import org.apache.doris.connector.api.scan.ConnectorScanRequest;
+
+import org.apache.paimon.catalog.Catalog;
+import org.apache.paimon.catalog.FileSystemCatalog;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.DataTypes;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Pins the {@code paimon.bucket} scan-range property (P4-2-a0).
+ *
+ * <p>WHY this property exists: a sibling connector can plan paimon splits on
behalf of its OWN table
+ * and then has to line each split up with its own per-bucket state. Today
that sibling is the fluss
+ * connector: a fluss table tiered into paimon keeps bucket-identical layout,
and the fluss connector
+ * binds the un-tiered log tail of bucket <i>b</i> to the lake splits of
bucket <i>b</i>. Without the
+ * bucket on the range there is nothing in a {@link PaimonScanRange} that says
which bucket it came
+ * from (the JNI arm carries only an opaque serialized split; the native arm
only a file path), so the
+ * sibling would have to fall back to whole-table binding — correct but
wasteful — or parse the
+ * sibling's internal directory layout, which the JNI arm does not even expose.
+ *
+ * <p>The fixture is deliberately MULTI-bucket: with a single bucket every
range would carry "0" and a
+ * hard-coded constant would pass. Each test therefore asserts the ranges
reproduce the split-side
+ * bucket SET, which a constant cannot.
+ */
+public class PaimonScanRangeBucketTest {
+
+ /**
+ * A two-bucket PK table with rows in BOTH buckets. PK {@code id} hashes
into
+ * {@code bucket = hash(id) % 2}; ids 1..8 cover both buckets for paimon's
hash function.
+ */
+ private static Table createTwoBucketTable(Catalog catalog) throws
Exception {
+ catalog.createDatabase("db", false);
+ Identifier id = Identifier.create("db", "t");
+ catalog.createTable(id, Schema.newBuilder()
+ .column("id", DataTypes.INT())
+ .column("val", DataTypes.BIGINT())
+ .primaryKey("id")
+ .option("bucket", "2")
+ .build(), false);
+ Table table = catalog.getTable(id);
+
+ BatchWriteBuilder wb = table.newBatchWriteBuilder();
+ try (BatchTableWrite write = wb.newWrite()) {
+ for (int i = 1; i <= 8; i++) {
+ write.write(GenericRow.of(i, (long) i * 100));
+ }
+ List<CommitMessage> messages = write.prepareCommit();
+ try (BatchTableCommit commit = wb.newCommit()) {
+ commit.commit(messages);
+ }
+ }
+ return table;
+ }
+
+ /** The buckets paimon's own read plan reports — the reference the ranges
must reproduce. */
+ private static Set<Integer> planBuckets(Table table) throws Exception {
+ Set<Integer> buckets = new TreeSet<>();
+ for (Split s : table.newReadBuilder().newScan().plan().splits()) {
+ if (s instanceof DataSplit) {
+ buckets.add(((DataSplit) s).bucket());
+ }
+ }
+ return buckets;
+ }
+
+ /** The buckets the planned ranges claim, as ints. Fails the test if any
range omits the property. */
+ private static Set<Integer> rangeBuckets(List<ConnectorScanRange> ranges) {
+ Set<Integer> buckets = new TreeSet<>();
+ for (ConnectorScanRange r : ranges) {
+ String bucket = r.getProperties().get("paimon.bucket");
+ Assertions.assertNotNull(bucket,
+ "every DataSplit-backed range must carry paimon.bucket;
missing on " + r);
+ buckets.add(Integer.parseInt(bucket));
+ }
+ return buckets;
+ }
+
+ private static PaimonScanPlanProvider providerFor(Table table) {
+ RecordingPaimonCatalogOps ops = new RecordingPaimonCatalogOps();
+ ops.table = table;
+ return new PaimonScanPlanProvider(Collections.emptyMap(), ops);
+ }
+
+ private static PaimonTableHandle handleFor(String tableName) {
+ return new PaimonTableHandle("db", tableName,
+ Collections.emptyList(), Collections.emptyList());
+ }
+
+ @Test
+ public void nativeRangesCarryTheBucketOfTheSplitTheyCameFrom(@TempDir Path
warehouse)
+ throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ Table table = createTwoBucketTable(catalog);
+ Set<Integer> expected = planBuckets(table);
+ Assertions.assertTrue(expected.size() >= 2,
+ "fixture precondition: the table must really span >=2
buckets, got " + expected);
+
+ List<ConnectorScanRange> ranges = providerFor(table).planScan(
+ sessionWithProps(Collections.emptyMap()),
+ ConnectorScanRequest.builder(handleFor("t"),
noColumns()).build());
+
+ Assertions.assertFalse(ranges.isEmpty(), "the fixture must plan at
least one range");
+ for (ConnectorScanRange r : ranges) {
+ Assertions.assertTrue(((PaimonScanRange)
r).isNativeReadRange(),
+ "fixture precondition: this arm must exercise the
NATIVE range builder");
+ }
+ // WHY: a native range is one sub-range of one raw file of one
DataSplit, so the bucket has
+ // to be threaded down from the DataSplit loop through
buildNativeRanges/buildNativeRange.
+ // MUTATION: hard-coding 0 (or dropping .bucket() from the native
builder) -> {0} instead of
+ // {0, 1} -> red; the multi-bucket fixture is what makes the
constant detectable.
+ Assertions.assertEquals(expected, rangeBuckets(ranges),
+ "native ranges must reproduce exactly the buckets paimon's
own plan reports");
+ }
+ }
+
+ @Test
+ public void jniRangesCarryTheBucketOfTheSplitTheyCameFrom(@TempDir Path
warehouse)
+ throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ Table table = createTwoBucketTable(catalog);
+ Set<Integer> expected = planBuckets(table);
+ Assertions.assertTrue(expected.size() >= 2,
+ "fixture precondition: the table must really span >=2
buckets, got " + expected);
+
+ List<ConnectorScanRange> ranges = providerFor(table).planScan(
+
sessionWithProps(Collections.singletonMap("force_jni_scanner", "true")),
+ ConnectorScanRequest.builder(handleFor("t"),
noColumns()).build());
+
+ Assertions.assertFalse(ranges.isEmpty(), "the fixture must plan at
least one range");
+ for (ConnectorScanRange r : ranges) {
+
Assertions.assertTrue(r.getProperties().containsKey("paimon.split"),
+ "fixture precondition: this arm must exercise the JNI
range builder");
+ }
+ // WHY: which BE reader a split ends up on (native vs JNI, a
session-level escape hatch the
+ // sibling does not control) must not change what the sibling can
learn about the split.
+ // If only the native arm carried the bucket, turning on
force_jni_scanner would silently
+ // break the sibling's binding. MUTATION: setting .bucket() only
on the native arm -> the
+ // rangeBuckets assertNotNull fires -> red.
+ Assertions.assertEquals(expected, rangeBuckets(ranges),
+ "JNI ranges must reproduce exactly the buckets paimon's
own plan reports");
+ }
+ }
+
+ @Test
+ public void collapsedCountRangeCarriesNoBucket(@TempDir Path warehouse)
throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ Table table = createTwoBucketTable(catalog);
+ Assertions.assertTrue(planBuckets(table).size() >= 2,
+ "fixture precondition: >=2 buckets, so the collapse really
does span buckets");
+
+ List<ConnectorScanRange> ranges = providerFor(table).planScan(
+ sessionWithProps(Collections.emptyMap()),
+ ConnectorScanRequest.builder(handleFor("t"), noColumns())
+ .countPushdown(true).build());
+
+ // WHY: the count collapse folds the splits of ALL buckets into
ONE range carrying the summed
+ // total, so no single bucket number is true of it. Stamping the
representative split's bucket
+ // would hand a sibling a range that claims to be bucket b while
actually standing for every
+ // bucket — it would suppress/join against the wrong state. Absent
is the honest answer, and
+ // the sibling is required to fail loud rather than guess (it
never forwards count pushdown,
+ // so it must never see one of these).
+ // MUTATION: adding .bucket() to buildCountRange -> the count
range carries one -> red.
+ int countRanges = 0;
+ for (ConnectorScanRange r : ranges) {
+ if (r.getProperties().containsKey("paimon.row_count")) {
+ ++countRanges;
+
Assertions.assertFalse(r.getProperties().containsKey("paimon.bucket"),
+ "the collapsed count range spans every bucket, so
it must claim none");
+ }
+ }
+ Assertions.assertEquals(1, countRanges,
+ "fixture precondition: count pushdown must produce exactly
one collapsed range");
+ }
+ }
+
+ @Test
+ public void systemTableSplitCarriesNoBucket(@TempDir Path warehouse)
throws Exception {
+ try (Catalog catalog = new FileSystemCatalog(LocalFileIO.create(),
+ new org.apache.paimon.fs.Path(warehouse.toUri()))) {
+ createTwoBucketTable(catalog);
+ Table snapshots = catalog.getTable(Identifier.create("db",
"t$snapshots"));
+
+ List<ConnectorScanRange> ranges = providerFor(snapshots).planScan(
+ sessionWithProps(Collections.emptyMap()),
+ ConnectorScanRequest.builder(handleFor("t$snapshots"),
noColumns()).build());
+
+ Assertions.assertFalse(ranges.isEmpty(), "a snapshots system table
must plan >=1 range");
+ // WHY: a system-table split is not a DataSplit and has no bucket
at all — fabricating one
+ // (say 0) would be a lie a sibling could act on. MUTATION:
setting .bucket() unconditionally
+ // in buildJniScanRange (dropping the isDataSplit gate) -> red.
This also documents the shape
+ // the sibling must reject: it plans only data reads, so a
bucket-less range reaching its
+ // wrapper means the contract broke.
+ for (ConnectorScanRange r : ranges) {
+
Assertions.assertFalse(r.getProperties().containsKey("paimon.bucket"),
+ "a non-DataSplit system split has no bucket, so it
must not claim one");
+ }
+ }
+ }
+
+ private static List<ConnectorColumnHandle> noColumns() {
+ return Collections.emptyList();
+ }
+
+ private static ConnectorSession sessionWithProps(Map<String, String>
sessionProps) {
+ return new ConnectorSession() {
+ @Override
+ public String getQueryId() {
+ return "q";
+ }
+
+ @Override
+ public String getUser() {
+ return "u";
+ }
+
+ @Override
+ public String getTimeZone() {
+ return "UTC";
+ }
+
+ @Override
+ public String getLocale() {
+ return "en_US";
+ }
+
+ @Override
+ public long getCatalogId() {
+ return 0;
+ }
+
+ @Override
+ public String getCatalogName() {
+ return "c";
+ }
+
+ @Override
+ public <T> T getProperty(String name, Class<T> type) {
+ return null;
+ }
+
+ @Override
+ public Map<String, String> getCatalogProperties() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public Map<String, String> getSessionProperties() {
+ return sessionProps;
+ }
+ };
+ }
+}
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 5f2d7c49abe..ac936d204dd 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
@@ -627,6 +627,29 @@ public class PluginDrivenScanNode extends
FileQueryScanNode {
return onPluginClassLoader(scanProvider, () ->
scanProvider.classifyColumn(columnName));
}
+ /**
+ * Asks the connector which columns BE must read for this scan even when
the query references none of them
+ * ({@link ConnectorScanPlanProvider#getMustReadColumns}), so the
translator can keep their slots instead of
+ * pruning them ({@code
PhysicalPlanTranslator.preserveConnectorMustReadSlots}, the plugin-table
counterpart
+ * of {@code preserveExtraStorageKeySlots} for aggregate / merge-on-read
unique-key OLAP tables).
+ *
+ * <p>Asked through the SAME memoized provider the rest of the scan uses,
so a connector that memoizes the
+ * decision on its provider instance answers this question and plans its
splits from one decision — the
+ * whole point, since a column preserved here and a split plan that
assumes otherwise disagree silently.
+ * A connector with no scan provider (no scan capability) needs nothing.
Public + overridable because the
+ * caller is the translator, in another package, and so the preservation
is unit-testable without a live
+ * connector (mirrors {@link #classifyColumnByConnector}, whose caller is
this class).</p>
+ */
+ public Set<String> mustReadColumnsFromConnector() {
+ ConnectorScanPlanProvider scanProvider = resolveScanProvider();
+ if (scanProvider == null) {
+ return Collections.emptySet();
+ }
+ Set<String> columns = onPluginClassLoader(scanProvider,
+ () -> scanProvider.getMustReadColumns(connectorSession,
currentHandle));
+ return columns == null ? Collections.emptySet() : columns;
+ }
+
/**
* Lets the owning connector adjust the compression type this node
inferred from the split's file path
* before it is shipped to BE, WITHOUT any source-specific code here: the
base inference runs first, then
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
index c09072174ce..60c9a7bfb61 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslator.java
@@ -3041,6 +3041,8 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
}
if (scanNode instanceof OlapScanNode) {
preserveExtraStorageKeySlots((OlapScanNode) scanNode,
requiredWithVirtualColumns);
+ } else if (scanNode instanceof PluginDrivenScanNode) {
+ preserveConnectorMustReadSlots((PluginDrivenScanNode) scanNode,
requiredWithVirtualColumns);
}
// Find the smallest column, for count(*) or other situation that slot
is empty after prune
SlotDescriptor smallest =
getSmallestSlot(scanNode.getTupleDesc().getSlots());
@@ -3078,6 +3080,43 @@ public class PhysicalPlanTranslator extends
DefaultPlanVisitor<PlanFragment, Pla
}
}
+ /**
+ * Keeps the slots of the columns a plugin connector must read for this
scan even when the query
+ * references none of them — the plugin-table counterpart of {@link
#preserveExtraStorageKeySlots}, and
+ * for the same reason: a reader that merges or suppresses rows by key
needs the key whether or not the
+ * user selected it. The connector answers per scan
+ * ({@code ConnectorScanPlanProvider.getMustReadColumns}, empty by
default), so every connector that needs
+ * nothing beyond the projection prunes exactly as before.
+ *
+ * <p>Only the scan's tuple is widened. The project above it was already
given its own output tuple and
+ * project list a few lines up, so a column preserved here is read and
then dropped — it never reaches the
+ * query's output.</p>
+ *
+ * <p>A name that matches no slot fails the query loud rather than being
skipped: it means the connector
+ * and the engine disagree about the table's columns, and the connector's
reader would then be handed a
+ * scan missing a column it said it needs — silently wrong rows, not an
error. Static + visible for testing
+ * so the ask-and-preserve step is pinned without a live connector.</p>
+ */
+ @VisibleForTesting
+ static void preserveConnectorMustReadSlots(PluginDrivenScanNode scanNode,
Set<SlotId> requiredSlotIds) {
+ Set<String> mustRead = scanNode.mustReadColumnsFromConnector();
+ if (mustRead.isEmpty()) {
+ return;
+ }
+ Set<String> missing = Sets.newLinkedHashSet(mustRead);
+ for (SlotDescriptor slot : scanNode.getTupleDesc().getSlots()) {
+ Column column = slot.getColumn();
+ if (column != null && mustRead.contains(column.getName())) {
+ requiredSlotIds.add(slot.getId());
+ missing.remove(column.getName());
+ }
+ }
+ if (!missing.isEmpty()) {
+ throw new AnalysisException("connector requires column(s) " +
missing
+ + " to be read, but the scan has no such column");
+ }
+ }
+
private boolean shouldPreserveStorageKeySlots(OlapScanNode scanNode) {
long selectedIndexId = scanNode.getSelectedIndexId() == -1
? scanNode.getOlapTable().getBaseIndexId()
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMustReadColumnsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMustReadColumnsTest.java
new file mode 100644
index 00000000000..a5ff8203b9f
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/PluginDrivenScanNodeMustReadColumnsTest.java
@@ -0,0 +1,129 @@
+// 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.common.jmockit.Deencapsulation;
+import org.apache.doris.connector.api.Connector;
+import org.apache.doris.connector.api.ConnectorSession;
+import org.apache.doris.connector.api.handle.ConnectorTableHandle;
+import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider;
+
+import com.google.common.collect.ImmutableSet;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.Set;
+
+/**
+ * Guards {@link PluginDrivenScanNode#mustReadColumnsFromConnector()} — the
seam the translator asks before it
+ * prunes a plugin table's scan slots ({@code
PhysicalPlanTranslator.preserveConnectorMustReadSlots}), so a
+ * connector whose BE-side reader merges or suppresses rows by key gets that
key read even when the query
+ * never mentions it.
+ *
+ * <p><b>WHY this matters (Rule 9):</b> the answer is the whole contract
between plan translation and split
+ * planning. Answering for the WRONG handle, or resolving a FRESH provider to
ask, would let the connector
+ * decide "combine the two sources" at split time while the tuple was pruned
as if it had said no — and BE
+ * would be told to suppress rows by a column that is not there. The memo
assertion below is what pins
+ * "asked and planned through one provider instance".</p>
+ *
+ * <p>Driven on a {@code CALLS_REAL_METHODS} node with only the
connector/session/handle fields injected —
+ * the same technique as {@link
PluginDrivenScanNodeScanProviderSelectionTest}.</p>
+ */
+public class PluginDrivenScanNodeMustReadColumnsTest {
+
+ private static PluginDrivenScanNode nodeWith(ConnectorScanPlanProvider
provider,
+ ConnectorTableHandle handle, ConnectorSession session) {
+ PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class,
Mockito.CALLS_REAL_METHODS);
+ Connector connector = Mockito.mock(Connector.class);
+
Mockito.when(connector.getScanPlanProvider(handle)).thenReturn(provider);
+ Deencapsulation.setField(node, "connector", connector);
+ Deencapsulation.setField(node, "currentHandle", handle);
+ Deencapsulation.setField(node, "connectorSession", session);
+ return node;
+ }
+
+ @Test
+ public void forwardsTheConnectorsAnswerForTheScannedHandle() {
+ ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+ ConnectorSession session = Mockito.mock(ConnectorSession.class);
+ ConnectorScanPlanProvider provider =
Mockito.mock(ConnectorScanPlanProvider.class);
+ Mockito.when(provider.getMustReadColumns(session,
handle)).thenReturn(ImmutableSet.of("id", "part"));
+ PluginDrivenScanNode node = nodeWith(provider, handle, session);
+
+ Set<String> mustRead = node.mustReadColumnsFromConnector();
+
+ // WHY: the connector answers PER SCAN, from the handle this scan
holds. MUTATION: passing a
+ // different handle (or the table's original one after pushdown
refined it) makes the connector
+ // answer about another read -> the stub returns empty -> red.
+ Assertions.assertEquals(ImmutableSet.of("id", "part"), mustRead);
+ Mockito.verify(provider).getMustReadColumns(session, handle);
+ }
+
+ @Test
+ public void connectorWithoutScanCapabilityNeedsNothing() {
+ ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+ PluginDrivenScanNode node = nodeWith(null, handle,
Mockito.mock(ConnectorSession.class));
+
+ // WHY: getScanPlanProvider() is null for a connector with no scan
capability; every other resolver
+ // in this node degrades to its default rather than throwing.
MUTATION: dropping the null check ->
+ // NPE during plan translation for such a catalog -> red.
+ Assertions.assertEquals(Collections.emptySet(),
node.mustReadColumnsFromConnector());
+ }
+
+ @Test
+ public void nullAnswerIsReadAsNoExtraColumns() {
+ ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+ ConnectorSession session = Mockito.mock(ConnectorSession.class);
+ ConnectorScanPlanProvider provider =
Mockito.mock(ConnectorScanPlanProvider.class);
+ Mockito.when(provider.getMustReadColumns(session,
handle)).thenReturn(null);
+ PluginDrivenScanNode node = nodeWith(provider, handle, session);
+
+ // WHY: a third-party connector may return null where the SPI says
"empty". Turning that into an
+ // NPE inside plan translation would blame the engine for a
connector's slip. MUTATION: returning
+ // the raw answer -> NPE in the translator's isEmpty() -> red.
+ Assertions.assertEquals(Collections.emptySet(),
node.mustReadColumnsFromConnector());
+ }
+
+ @Test
+ public void asksThroughTheSameProviderInstanceThatWillPlanTheSplits() {
+ ConnectorTableHandle handle = Mockito.mock(ConnectorTableHandle.class);
+ ConnectorSession session = Mockito.mock(ConnectorSession.class);
+ ConnectorScanPlanProvider provider =
Mockito.mock(ConnectorScanPlanProvider.class);
+ Mockito.when(provider.getMustReadColumns(session,
handle)).thenReturn(ImmutableSet.of("id"));
+ Connector connector = Mockito.mock(Connector.class);
+
Mockito.when(connector.getScanPlanProvider(handle)).thenReturn(provider);
+ PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class,
Mockito.CALLS_REAL_METHODS);
+ Deencapsulation.setField(node, "connector", connector);
+ Deencapsulation.setField(node, "currentHandle", handle);
+ Deencapsulation.setField(node, "connectorSession", session);
+
+ node.mustReadColumnsFromConnector();
+ Object providerForSplits = Deencapsulation.invoke(node,
"resolveScanProvider");
+
+ // WHY: the connector is allowed to memoize "do I combine two
sources?" on its provider instance,
+ // and MUST reach the same answer when it plans the splits later — the
columns kept here and the
+ // splits planned there have to come from one decision. A fresh
provider per question loses that
+ // memo and lets the two disagree. MUTATION: asking via
connector.getScanPlanProvider(...) directly
+ // instead of the memoized resolveScanProvider() -> two instances + a
second resolve -> red.
+ Assertions.assertSame(provider, providerForSplits,
+ "the must-read question must go through the same memoized
provider as split planning");
+ Mockito.verify(connector,
Mockito.times(1)).getScanPlanProvider(handle);
+ }
+}
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorMustReadSlotsTest.java
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorMustReadSlotsTest.java
new file mode 100644
index 00000000000..5f1caa9b39d
--- /dev/null
+++
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorMustReadSlotsTest.java
@@ -0,0 +1,169 @@
+// 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.nereids.glue.translator;
+
+import org.apache.doris.analysis.DescriptorTable;
+import org.apache.doris.analysis.SlotDescriptor;
+import org.apache.doris.analysis.SlotId;
+import org.apache.doris.analysis.TupleDescriptor;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.datasource.scan.PluginDrivenScanNode;
+import org.apache.doris.nereids.exceptions.AnalysisException;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Guards {@code PhysicalPlanTranslator.preserveConnectorMustReadSlots} — the
plugin-table branch next to
+ * {@code preserveExtraStorageKeySlots}, which keeps the slots a connector
says its BE-side reader must read
+ * even when the query references none of them.
+ *
+ * <p><b>WHY this matters (Rule 9):</b> the scan's tuple is where the columns
BE reads are decided
+ * ({@code FileQueryScanNode.updateRequiredSlots} rebuilds the required-slot
list from exactly these slots
+ * after planning). A reader that suppresses or merges rows by key and does
not get the key back reads the
+ * key-less rows and emits duplicates — no error anywhere. Doris does the same
preservation for its own
+ * aggregate / merge-on-read unique-key tables; this is that mechanism for
plugin connectors.</p>
+ *
+ * <p>These tests drive the extracted static entry point directly with a real
{@link TupleDescriptor} and a
+ * {@code CALLS_REAL_METHODS} node whose connector answer is stubbed —
building a translator over a live
+ * plugin catalog needs a harness this module does not have. What they do NOT
cover, because it is decided
+ * before this branch runs and by generic code: the project above the scan
gets its own output tuple, so a
+ * column preserved here is read and then dropped rather than returned. The
fluss suites' row baselines are
+ * the end-to-end guard for that.</p>
+ */
+public class PhysicalPlanTranslatorMustReadSlotsTest {
+
+ private static final DescriptorTable DESC_TABLE = new DescriptorTable();
+
+ /** A scan tuple holding one slot per named column, in order. */
+ private static TupleDescriptor tupleOf(String... columnNames) {
+ TupleDescriptor tuple = DESC_TABLE.createTupleDescriptor();
+ for (String name : columnNames) {
+ SlotDescriptor slot = DESC_TABLE.addSlotDescriptor(tuple);
+ slot.setColumn(new Column(name, PrimitiveType.INT));
+ }
+ return tuple;
+ }
+
+ private static PluginDrivenScanNode nodeAnswering(TupleDescriptor tuple,
Set<String> mustRead) {
+ PluginDrivenScanNode node = Mockito.mock(PluginDrivenScanNode.class,
Mockito.CALLS_REAL_METHODS);
+ Mockito.doReturn(tuple).when(node).getTupleDesc();
+ Mockito.doReturn(mustRead).when(node).mustReadColumnsFromConnector();
+ return node;
+ }
+
+ private static SlotId slotIdOf(TupleDescriptor tuple, String columnName) {
+ for (SlotDescriptor slot : tuple.getSlots()) {
+ if (slot.getColumn().getName().equals(columnName)) {
+ return slot.getId();
+ }
+ }
+ throw new IllegalStateException("no slot for " + columnName);
+ }
+
+ @Test
+ public void connectorNamedColumnsSurvivePruning() {
+ TupleDescriptor tuple = tupleOf("id", "name", "amount");
+ PluginDrivenScanNode node = nodeAnswering(tuple,
ImmutableSet.of("id"));
+ // "select name": only that slot is required by the project above the
scan.
+ Set<SlotId> required = Sets.newHashSet(slotIdOf(tuple, "name"));
+
+ PhysicalPlanTranslator.preserveConnectorMustReadSlots(node, required);
+
+ // WHY: 'id' is what the connector's reader needs to suppress rows;
without it in the required set
+ // the removeIf below this call drops it from the tuple and BE reads
key-less rows. MUTATION:
+ // dropping the branch (or the add) -> 'id' absent -> red.
+ Assertions.assertEquals(ImmutableSet.of(slotIdOf(tuple, "name"),
slotIdOf(tuple, "id")), required);
+ }
+
+ @Test
+ public void connectorThatNeedsNothingChangesNothing() {
+ TupleDescriptor tuple = tupleOf("id", "name", "amount");
+ PluginDrivenScanNode node = nodeAnswering(tuple,
Collections.emptySet());
+ Set<SlotId> required = Sets.newHashSet(slotIdOf(tuple, "name"));
+
+ PhysicalPlanTranslator.preserveConnectorMustReadSlots(node, required);
+
+ // WHY: this is the gate that keeps the branch inert for every
connector that never opted in — and
+ // for an opted-in connector on a scan it decided NOT to combine (a
fluss table read from fluss
+ // alone), which is exactly the "only when it is really needed"
requirement. MUTATION: preserving
+ // unconditionally (e.g. the whole primary key regardless of the
decision) -> an extra slot -> red.
+ Assertions.assertEquals(Collections.singleton(slotIdOf(tuple,
"name")), required);
+ }
+
+ @Test
+ public void everyNamedColumnIsPreservedNotJustTheFirst() {
+ TupleDescriptor tuple = tupleOf("k1", "k2", "payload");
+ PluginDrivenScanNode node = nodeAnswering(tuple, ImmutableSet.of("k1",
"k2"));
+ Set<SlotId> required = Sets.newHashSet(slotIdOf(tuple, "payload"));
+
+ PhysicalPlanTranslator.preserveConnectorMustReadSlots(node, required);
+
+ // WHY: composite keys are the normal case for the readers this exists
for; keeping only one column
+ // of a two-column key compares the wrong thing. MUTATION: `break`
after the first match -> red.
+ Assertions.assertEquals(
+ ImmutableSet.of(slotIdOf(tuple, "payload"), slotIdOf(tuple,
"k1"), slotIdOf(tuple, "k2")),
+ required);
+ }
+
+ @Test
+ public void preservedSlotSurvivesThePruneItself() {
+ TupleDescriptor tuple = tupleOf("id", "name", "amount");
+ PluginDrivenScanNode node = nodeAnswering(tuple,
ImmutableSet.of("id"));
+ Set<SlotId> required = Sets.newHashSet(slotIdOf(tuple, "name"));
+
+ // The real prune step, driven end to end: it is what decides which
slots the scan reads, and the
+ // branch under test sits inside it.
+ Deencapsulation.invoke(new PhysicalPlanTranslator(),
"updateScanSlotsMaterialization",
+ node, required, Sets.newHashSet(), new
PlanTranslatorContext());
+
+ // WHY: this is the only assertion that also pins the DISPATCH — that
a plugin-driven scan reaches
+ // the branch at all. MUTATION: deleting the `else if (scanNode
instanceof PluginDrivenScanNode)`
+ // arm -> 'id' pruned away -> red. MUTATION: preserving AFTER the
removeIf -> also red.
+ Assertions.assertEquals(ImmutableList.of("id", "name"),
+ tuple.getSlots().stream().map(s ->
s.getColumn().getName()).collect(Collectors.toList()),
+ "the connector's column must be read; the unreferenced one
must still be pruned");
+ }
+
+ @Test
+ public void columnTheScanDoesNotHaveFailsLoud() {
+ TupleDescriptor tuple = tupleOf("id", "name");
+ PluginDrivenScanNode node = nodeAnswering(tuple, ImmutableSet.of("id",
"ghost"));
+ Set<SlotId> required = Sets.newHashSet(slotIdOf(tuple, "name"));
+
+ AnalysisException thrown =
Assertions.assertThrows(AnalysisException.class,
+ () ->
PhysicalPlanTranslator.preserveConnectorMustReadSlots(node, required));
+
+ // WHY: a name matching no slot means the connector and the engine
disagree about the table. Reading
+ // on would hand the reader a scan without a column it said it needs —
wrong rows, silently. The
+ // message must name the column, because that is the only clue to
which side is stale. MUTATION:
+ // skipping unknown names instead of throwing -> no exception -> red.
+ Assertions.assertTrue(thrown.getMessage().contains("ghost"),
+ "the failure must name the column the scan does not have: " +
thrown.getMessage());
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]