Skip to content

Commit 6d7992f

Browse files
authored
[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.
1 parent a042859 commit 6d7992f

16 files changed

Lines changed: 1028 additions & 18 deletions

File tree

be/src/exec/scan/file_scanner_v2.cpp

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ Status FileScannerV2::_open_impl(RuntimeState* state) {
403403
if (_first_scan_range) {
404404
RETURN_IF_ERROR(_create_table_reader_for_format(_current_range, &_table_reader));
405405
DORIS_CHECK(_table_reader != nullptr);
406+
_table_reader_format = table_format_name(_current_range);
406407
RETURN_IF_ERROR(_init_expr_ctxes());
407408
RETURN_IF_ERROR(_init_table_reader(_current_range));
408409
}
@@ -508,6 +509,14 @@ Status FileScannerV2::_prepare_next_split(bool* eos) {
508509
DORIS_CHECK(_table_reader != nullptr);
509510
_current_range_path = _current_range.path;
510511

512+
bool reader_rebuilt = false;
513+
RETURN_IF_ERROR(_rebuild_table_reader_if_format_changed(_current_range, &reader_rebuilt));
514+
if (reader_rebuilt) {
515+
// Same init the first reader got. The expression contexts are NOT rebuilt: they are
516+
// per-scanner and format-independent, and _init_expr_ctxes is not idempotent.
517+
RETURN_IF_ERROR(_init_table_reader(_current_range));
518+
}
519+
511520
const auto format_type = get_range_format_type(*_params, _current_range);
512521
_init_adaptive_batch_size_state(format_type);
513522
if (_block_size_predictor != nullptr) {
@@ -590,6 +599,31 @@ Status FileScannerV2::_init_table_reader(const TFileRangeDesc& range) {
590599
return Status::OK();
591600
}
592601

602+
Status FileScannerV2::_rebuild_table_reader_if_format_changed(const TFileRangeDesc& range,
603+
bool* rebuilt) {
604+
// The reader is chosen by the range's table format, not the node's, because one node can be given
605+
// both: a connector that reads a table as a lake plus the log written after it plans its lake half
606+
// through a sibling connector and its log half itself, and both land here as ranges of the same
607+
// scan. Built once from the first range and never revisited, the reader is then handed a range of
608+
// the other format -- which does not fail cleanly. It fails as whatever that reader makes of a
609+
// foreign range, e.g. paimon's reporting an unsupported file format for a range that carries no
610+
// paimon parameters at all. And which ranges share a scanner is up to the engine's assignment, so
611+
// the same query succeeds or fails by how the ranges happened to be dealt out.
612+
//
613+
// Split out from _prepare_next_split so the decision can be tested on its own: re-initializing the
614+
// new reader needs scan-wide state that choosing it does not, so that step stays with the caller.
615+
auto table_format = table_format_name(range);
616+
if (table_format == _table_reader_format) {
617+
*rebuilt = false;
618+
return Status::OK();
619+
}
620+
RETURN_IF_ERROR(_create_table_reader_for_format(range, &_table_reader));
621+
DORIS_CHECK(_table_reader != nullptr);
622+
_table_reader_format = std::move(table_format);
623+
*rebuilt = true;
624+
return Status::OK();
625+
}
626+
593627
Status FileScannerV2::_create_table_reader_for_format(
594628
const TFileRangeDesc& range, std::unique_ptr<format::TableReader>* reader) const {
595629
DORIS_CHECK(reader != nullptr);

be/src/exec/scan/file_scanner_v2.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ class FileScannerV2 final : public Scanner {
129129
Status _init_table_reader(const TFileRangeDesc& range);
130130
Status _create_table_reader_for_format(const TFileRangeDesc& range,
131131
std::unique_ptr<format::TableReader>* reader) const;
132+
// Replaces _table_reader when {@code range} carries a different table format than the one it was
133+
// built for, reporting whether it did. See the definition for why the reader follows the range.
134+
Status _rebuild_table_reader_if_format_changed(const TFileRangeDesc& range, bool* rebuilt);
132135
Status _prepare_table_reader_split(const TFileRangeDesc& range,
133136
std::map<std::string, Field> partition_values);
134137
static bool _should_skip_not_found(const Status& status, bool ignore_not_found);
@@ -182,6 +185,10 @@ class FileScannerV2 final : public Scanner {
182185
std::string _current_range_path;
183186

184187
std::unique_ptr<format::TableReader> _table_reader;
188+
// The table format _table_reader was built for. A scan node may mix table formats -- a fluss
189+
// union read gives one node its lake half as paimon ranges and its log half as fluss ones -- and
190+
// the reader is format-specific, so it is rebuilt whenever this stops matching the range.
191+
std::string _table_reader_format;
185192
std::vector<format::ColumnDefinition> _projected_columns;
186193
// File formats without embedded schema, such as CSV, still need the FE slot descriptors in
187194
// file-column order. This mirrors old FileScanner::_file_slot_descs and is passed only to

be/src/service/CMakeLists.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,24 @@ if (${MAKE_TEST} STREQUAL "OFF" AND ${BUILD_BENCHMARK} STREQUAL "OFF")
4949
# This permits libraries loaded by dlopen to link to the symbols in the program.
5050
set_target_properties(doris_be PROPERTIES ENABLE_EXPORTS 1)
5151

52+
# ...but not the symbols of the RocksDB we link statically. Exporting those makes this
53+
# executable the definition every later-loaded library binds to, and a JNI library that
54+
# carries its own RocksDB then runs half on ours: the fluss scanner bundles frocksdbjni,
55+
# whose librocksdbjni.so defines 2576 rocksdb symbols under names identical to ours but
56+
# was built against the pre-C++11 libstdc++ string ABI. Objects laid out by one and used
57+
# by the other yield a garbage length, an std::bad_alloc that escapes the JNI frame, and
58+
# an aborted BE. Hiding this archive lets that library bind to its own copy.
59+
#
60+
# Scoped to the archive rather than dropping ENABLE_EXPORTS: what needs the exports is
61+
# native UDFs (runtime/user_function_cache.cpp dlopens them), and those use the Doris UDF
62+
# ABI, which has nothing to do with RocksDB. Crash stacks do not need it either -- they are
63+
# symbolized from debug info, which is why they name even anonymous-namespace functions.
64+
#
65+
# The same library also duplicates zstd, lz4, snappy, bzip2 and zlib symbols. Those are C
66+
# ABIs, stable across versions and layout-free, so they are left alone until something
67+
# shows otherwise -- unlike RocksDB, whose C++ objects are what actually corrupt.
68+
target_link_options(doris_be PRIVATE "-Wl,--exclude-libs,librocksdb.a")
69+
5270
target_link_libraries(doris_be
5371
${DORIS_LINK_LIBS}
5472
)

be/test/exec/scan/file_scanner_v2_test.cpp

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,6 +475,60 @@ TEST(FileScannerV2Test, JniCompatibilityShapesUseV2Scanner) {
475475
EXPECT_TRUE(FileScannerV2::is_supported(params, legacy_paimon_jni_range_without_reader_type()));
476476
}
477477

478+
// Scenario: one scan node is given ranges of two different table formats, which is what a connector
479+
// reading a table as a lake plus the log written after it produces -- its lake half planned by a
480+
// sibling connector, its own half by itself. The reader is format-specific, so it has to follow the
481+
// RANGE. Built once from the first range, it is later handed a foreign one and fails as whatever that
482+
// reader makes of it, not as a clean error; and since which ranges share a scanner is the engine's
483+
// assignment, the same query then succeeds or fails by how the ranges happened to be dealt out.
484+
TEST(FileScannerV2Test, TheTableReaderIsRebuiltWhenARangeChangesTableFormat) {
485+
RuntimeState state {TQueryOptions(), TQueryGlobals()};
486+
RuntimeProfile profile("file_scanner_v2_reader_per_range");
487+
TFileScanRangeParams params;
488+
params.__set_format_type(TFileFormatType::FORMAT_PARQUET);
489+
490+
FileScannerV2 scanner(&state, &profile, nullptr);
491+
scanner._params = &params;
492+
493+
const auto paimon_range = range_with_format("paimon", TFileFormatType::FORMAT_PARQUET);
494+
const auto hive_range = range_with_format("hive", TFileFormatType::FORMAT_PARQUET);
495+
496+
// Nothing has been built yet, so the first range always builds.
497+
bool rebuilt = false;
498+
ASSERT_TRUE(scanner._rebuild_table_reader_if_format_changed(paimon_range, &rebuilt).ok());
499+
EXPECT_TRUE(rebuilt);
500+
EXPECT_EQ(scanner._table_reader_format, "paimon");
501+
const auto* first_reader = scanner._table_reader.get();
502+
ASSERT_NE(first_reader, nullptr);
503+
504+
// A second range of the same format reuses it. Rebuilding here would be wasteful rather than
505+
// wrong, but it would also throw away per-reader state the next split expects to still be there.
506+
ASSERT_TRUE(scanner._rebuild_table_reader_if_format_changed(paimon_range, &rebuilt).ok());
507+
EXPECT_FALSE(rebuilt);
508+
EXPECT_EQ(scanner._table_reader.get(), first_reader);
509+
510+
// A range of another format must not be handed to the reader built for the first one.
511+
ASSERT_TRUE(scanner._rebuild_table_reader_if_format_changed(hive_range, &rebuilt).ok());
512+
EXPECT_TRUE(rebuilt);
513+
EXPECT_EQ(scanner._table_reader_format, "hive");
514+
EXPECT_NE(scanner._table_reader.get(), first_reader);
515+
516+
// And back again, because the ranges of a mixed node arrive interleaved rather than grouped.
517+
ASSERT_TRUE(scanner._rebuild_table_reader_if_format_changed(paimon_range, &rebuilt).ok());
518+
EXPECT_TRUE(rebuilt);
519+
EXPECT_EQ(scanner._table_reader_format, "paimon");
520+
521+
// The formats really do get different readers -- otherwise every assertion above would hold
522+
// just as well for a scanner that never rebuilt anything.
523+
std::unique_ptr<format::TableReader> as_paimon;
524+
std::unique_ptr<format::TableReader> as_hive;
525+
ASSERT_TRUE(scanner._create_table_reader_for_format(paimon_range, &as_paimon).ok());
526+
ASSERT_TRUE(scanner._create_table_reader_for_format(hive_range, &as_hive).ok());
527+
const format::TableReader& paimon_reader = *as_paimon;
528+
const format::TableReader& hive_reader = *as_hive;
529+
EXPECT_STRNE(typeid(paimon_reader).name(), typeid(hive_reader).name());
530+
}
531+
478532
TEST(FileScannerV2Test, FailedTableReaderCloseCanBeRetriedThroughScanner) {
479533
RuntimeState state {TQueryOptions(), TQueryGlobals()};
480534
RuntimeProfile profile("file_scanner_v2_close_retry");

fe/fe-connector/fe-connector-api/src/main/java/org/apache/doris/connector/api/scan/ConnectorScanPlanProvider.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.util.Map;
3131
import java.util.Optional;
3232
import java.util.OptionalLong;
33+
import java.util.Set;
3334

3435
/**
3536
* Plans the set of scan ranges (splits) needed to read a connector table.
@@ -148,6 +149,42 @@ default TFileCompressType adjustFileCompressType(TFileCompressType inferred) {
148149
return inferred;
149150
}
150151

152+
/**
153+
* The columns BE must READ for this scan even when the query references none of them, by Doris-side
154+
* column name. The engine keeps their slots in the scan's tuple instead of pruning them away; the
155+
* projection above the scan still removes them from the query's output, so the answer changes what is
156+
* read, never what is returned.
157+
*
158+
* <p>This exists for a connector whose BE-side reader needs a column to produce CORRECT ROWS rather than
159+
* to answer the query — a merge key, a suppression key, a row identity. Doris does the same thing for its
160+
* own aggregate / merge-on-read unique-key tables ({@code PhysicalPlanTranslator.preserveExtraStorageKeySlots}):
161+
* BE merges by key whether or not the user selected the key. Trino has no counterpart because its
162+
* connectors own the page source and can add such columns privately; here the reader is BE, so the columns
163+
* have to reach it through the plan.</p>
164+
*
165+
* <p>Answer per SCAN, not per table: a connector that only sometimes needs the column (e.g. only when it
166+
* decides to combine two sources) must return it only for those scans, and must reach the SAME decision
167+
* when it later plans the splits — the engine asks this during plan translation, strictly before
168+
* {@link #planScan}. Memoize that decision on the provider instance (the engine keeps one per scan node)
169+
* rather than deciding twice: two independent decisions can disagree, and then BE is asked to read a
170+
* column the tuple does not carry.</p>
171+
*
172+
* <p>Every name returned must be a column of the scanned table, spelled as Doris knows it (the same
173+
* identifier-mapped name {@link #classifyColumn} receives). A name that matches no slot in the scan's
174+
* tuple fails the query loud: it means the connector and the engine disagree about the table, and reading
175+
* on would silently produce whatever the connector's reader does without that column.</p>
176+
*
177+
* <p>The default returns an empty set — every connector whose reader needs nothing beyond the projection
178+
* is untouched, and its scans prune exactly as before.</p>
179+
*
180+
* @param session the current session
181+
* @param handle the table handle being scanned
182+
* @return Doris-side names of the columns to read regardless of the projection (default: empty)
183+
*/
184+
default Set<String> getMustReadColumns(ConnectorSession session, ConnectorTableHandle handle) {
185+
return Collections.emptySet();
186+
}
187+
151188
/**
152189
* Plans the scan described by {@code request}, returning the ranges that cover the requested data.
153190
*
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
package org.apache.doris.connector.api.scan;
19+
20+
import org.apache.doris.connector.api.ConnectorSession;
21+
import org.apache.doris.connector.api.handle.ConnectorTableHandle;
22+
23+
import org.junit.jupiter.api.Assertions;
24+
import org.junit.jupiter.api.Test;
25+
26+
import java.util.Collections;
27+
import java.util.List;
28+
import java.util.Set;
29+
30+
/**
31+
* Guards the additive {@code getMustReadColumns} SPI default on {@link ConnectorScanPlanProvider}.
32+
*
33+
* <p>WHY: the engine consults this on EVERY plugin-table scan that has a projection above it, and widens the
34+
* scan's tuple by whatever comes back. The default must therefore be empty, or every connector that never
35+
* asked for anything would start reading extra columns — and, worse, would fail the query loud when a name
36+
* it never returned matches no slot. This is the zero-break guard for es/jdbc/paimon/iceberg/hive/maxcompute,
37+
* none of which override it.</p>
38+
*/
39+
public class ConnectorScanPlanProviderMustReadColumnsTest {
40+
41+
/** Bare provider: only the abstract planScan implemented; everything else inherits SPI defaults. */
42+
private static final class BareProvider implements ConnectorScanPlanProvider {
43+
@Override
44+
public List<ConnectorScanRange> planScan(ConnectorSession session, ConnectorScanRequest request) {
45+
return Collections.emptyList();
46+
}
47+
}
48+
49+
/** A connector whose BE-side reader needs a merge key the query may not have selected. */
50+
private static final class KeyReadingProvider implements ConnectorScanPlanProvider {
51+
@Override
52+
public List<ConnectorScanRange> planScan(ConnectorSession session, ConnectorScanRequest request) {
53+
return Collections.emptyList();
54+
}
55+
56+
@Override
57+
public Set<String> getMustReadColumns(ConnectorSession session, ConnectorTableHandle handle) {
58+
return Collections.singleton("id");
59+
}
60+
}
61+
62+
@Test
63+
public void defaultAsksForNoExtraColumns() {
64+
ConnectorScanPlanProvider provider = new BareProvider();
65+
66+
// MUTATION: a default returning anything non-empty would widen every connector's scans and fail
67+
// loud on the first name that matches no slot -> red here first.
68+
Assertions.assertEquals(Collections.emptySet(), provider.getMustReadColumns(null, null),
69+
"a connector that never opted in must ask for no extra columns");
70+
}
71+
72+
@Test
73+
public void connectorThatOptsInIsObeyed() {
74+
ConnectorScanPlanProvider provider = new KeyReadingProvider();
75+
76+
Assertions.assertEquals(Collections.singleton("id"), provider.getMustReadColumns(null, null),
77+
"the engine must read back exactly what the connector asked for");
78+
}
79+
}

fe/fe-connector/fe-connector-paimon/src/main/java/org/apache/doris/connector/paimon/PaimonConnector.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.apache.doris.connector.api.ConnectorPartitionInfo;
2424
import org.apache.doris.connector.api.ConnectorSession;
2525
import org.apache.doris.connector.api.ConnectorValidationContext;
26+
import org.apache.doris.connector.api.handle.ConnectorTableHandle;
2627
import org.apache.doris.connector.api.scan.ConnectorScanPlanProvider;
2728
import org.apache.doris.connector.cache.ConnectorMetadataCache;
2829
import org.apache.doris.connector.metastore.HmsMetaStoreProperties;
@@ -254,6 +255,22 @@ public ConnectorMetadata getMetadata(ConnectorSession session) {
254255
properties, context, schemaAtMemo, latestSnapshotCache, partitionViewCache);
255256
}
256257

258+
/**
259+
* True for a handle this connector produced (a {@link PaimonTableHandle}). Tested against this connector's
260+
* OWN in-loader type, so a gateway connector that embeds this one as a sibling can route a foreign paimon
261+
* handle here without casting it across the plugin classloader split. Returns false for any other
262+
* connector's handle, so the gateway keeps looking.
263+
*
264+
* <p>The default is {@code false}, which for a sibling means every one of the gateway's guards silently
265+
* fails open and the first cast throws a ClassCastException instead — so this is required of any connector
266+
* used as a sibling, not an optimization. Same implementation as the iceberg and hudi siblings behind the
267+
* hms gateway.
268+
*/
269+
@Override
270+
public boolean ownsHandle(ConnectorTableHandle handle) {
271+
return handle instanceof PaimonTableHandle;
272+
}
273+
257274
@Override
258275
public void invalidateTable(String dbName, String tableName) {
259276
// REFRESH TABLE (and, via the generic PluginDrivenExternalCatalog DDL hook, a Doris-issued

0 commit comments

Comments
 (0)