You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@doris.apache.org by yi...@apache.org on 2026/10/08 03:23:52 UTC

(doris) 21/35: branch-4.1:[feat](paimon) support paimon-rust reader on BE (#66227)

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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git

commit e98db0594f7a0ed47e5aaafc53a05dc3b222a349
Author: Fangyuan Deng <98...@qq.com>
AuthorDate: Wed Sep 30 21:08:37 2026 +0800

    branch-4.1:[feat](paimon) support paimon-rust reader on BE (#66227)
    
    ### What problem does this PR solve?
    Related PR: #65883  (for master , current closed)
    
    **this pr add paimon rust reader  both in scanner v1 and v2.
    the scanner v1 code is just for legacy, will be removed later.**
    
      Problem Summary:
    
    Adds a third BE-side reader for Paimon FORMAT_JNI splits, backed by the
    `paimon-rust` C bindings (`libpaimon_c.a`, statically linked). Rust
    Reader is gated by the session variable
      `enable_paimon_rust_reader` (default `false`).
    
    
      Compared with the existing readers:
      - **vs paimon-jni**: no JVM round-trip on the hot path — the split is
    deserialized in-process and rows arrive via the Arrow C Data Interface,
    which shortens the scanner critical section and removes JNI copy cost.
    
    
      **Architecture**
    
    FE (`PaimonScanNode`): when `enable_paimon_rust_reader` is on and the
    split
      is a `DataSplit`, encode it with paimon's native binary serialization
      (`PaimonUtil.encodeDataSplitToString`), stamp the range with
      `TPaimonReaderType.PAIMON_RUST`, and attach `db_name` / `table_name` /
      `paimon_table` so BE can open the table through a catalog.
    
      BE (`FileScanner::_get_next_reader`): dispatches on `reader_type`. On
      `PAIMON_RUST` it instantiates `PaimonRustReader`, which:
    
      1. `paimon_catalog_create({warehouse, metastore=filesystem, ...})`
         - warehouse is derived from the table root path by stripping
           `/<db>.db/<table>` (paimon FS convention).
      2. `paimon_identifier_new(db, tbl)` → `paimon_catalog_get_table`
      3. `paimon_table_new_read_builder` → projection (case-insensitive) →
    optional filter from `PaimonRustPredicateConverter` (translates Doris
         push-down conjuncts into paimon-rust predicates, best-effort so any
    non-representable conjunct is silently dropped and re-applied by the
         engine).
      4. `paimon_plan_from_split_bytes(split_bytes)` — one BE scanner reads
         exactly its FE-planned split (no re-planning).
    5. `paimon_table_read_to_arrow` → streamed arrow record batches → block.
    
      Partition columns are back-filled in `on_after_read_block` from
      FE-provided `columns_from_path*`, mirroring `PaimonCppReader`. OSS/S3
      hadoop-style credential keys (`fs.oss.*` / `fs.s3a.*`) are remapped to
      `AWS_ACCESS_KEY` / `AWS_ENDPOINT` / ... so paimon-rust's FileIO can
      authenticate.
    
      **Thirdparty**
    
      - Introduces `paimon-rust-2fb5e49` (built with Rust 1.91.0, feature
        `paimon/storage-hdfs`, `--locked`).
      - Auto-installs `cbindgen` and regenerates the C header from the Rust
        extern "C" surface via a locally-written `cbindgen.toml`
    (`language=C, include_guard=PAIMON_C_H, pragma_once=true,
    cpp_compat=true`),
        so the shipped `paimon.h` always matches the built `.a`.
    
    
      ### Release note
    
      Add `enable_paimon_rust_reader` session variable. When enabled, Paimon
    non-native reads on BE use the paimon-rust reader instead of paimon-jni
    /
      paimon-cpp.
    
      ### Check List (For Author)
    
      - Test
          - [x] Unit Test
          - [x] Manual test (add detailed scripts or steps below)
    1. `CREATE CATALOG paimon_fs PROPERTIES ("type"="paimon",
    "paimon.catalog.type"="filesystem",
    "warehouse"="file:///media/ssd1/test_paimon/warehouse/");` when using
    "warehouse"="hdfs://.../warehouse/", add HADOOP_CONF_DIR=xx in be.conf
            2. `SET enable_paimon_rust_reader=true; SET force_jni_scanner=1`
    3. `SELECT` from a partitioned/DV paimon table; results must match
    `enable_paimon_rust_reader=false and force_jni_scanner=0`.
    4. `SELECT count(*)` — should hit table-level row count push-down.
    5. `WHERE a=1 AND b>2` — BE INFO log should show `paimon-rust predicate
    pushdown: applied`.
          - [x] Regression test
          - [ ] No need to test or manual test.
    
      - Behavior changed:
    - [x] No. Guarded by `enable_paimon_rust_reader` (default `false`); when
    off, all paths are unchanged.
    
      - Does this need documentation?
          - [ ] No.
    - [x] Yes. <!-- Add doris-website PR link here for the new session
    variable -->
    
      ### Check List (For Reviewer who merge this PR)
    
      - [ ] Confirm the release note
      - [ ] Confirm test cases
      - [ ] Confirm document
      - [ ] Add branch pick label
    
    ---------
    
    Co-authored-by: dengfangyuan <de...@kuaishou.com>
    Co-authored-by: Claude Opus 5 (1M context) <no...@anthropic.com>
    Co-authored-by: Gabriel <ga...@gmail.com>
---
 be/CMakeLists.txt                                  |    3 +-
 be/cmake/thirdparty.cmake                          |    7 +
 be/src/agent/heartbeat_server.cpp                  |    2 +
 be/src/exec/scan/file_scanner.cpp                  |   19 +
 be/src/exec/scan/file_scanner_v2.cpp               |   15 +-
 be/src/format_v2/table/paimon_reader.cpp           |   62 +-
 be/src/format_v2/table/paimon_reader.h             |   18 +-
 .../table/paimon_rust_predicate_converter.cpp      |  840 +++++++++++
 .../table/paimon_rust_predicate_converter.h        |  161 ++
 .../format_v2/table/paimon_rust_table_reader.cpp   |  924 ++++++++++++
 be/src/format_v2/table/paimon_rust_table_reader.h  |  188 +++
 be/test/exec/scan/file_scanner_v2_test.cpp         |   42 +
 .../test_data/paimon_rust_batch_size/README.md     |   32 +
 ...-3b6dc957-0664-4ec4-946c-9535d228957a-0.parquet |  Bin 0 -> 772 bytes
 .../paimon_rust_batch_size/schema/schema-0         |   22 +
 .../test_data/paimon_rust_batch_size/split.bin     |  Bin 0 -> 557 bytes
 be/test/format_v2/table/paimon_reader_test.cpp     |  167 ++-
 .../table/paimon_rust_predicate_converter_test.cpp |  674 +++++++++
 .../table/paimon_rust_table_reader_test.cpp        |  712 +++++++++
 build.sh                                           |   17 +-
 .../doris/common/profile/ExecutionProfile.java     |   69 +-
 .../org/apache/doris/common/profile/Profile.java   |   68 +
 .../doris/common/profile/SummaryProfile.java       |   10 +
 .../paimon/DorisToPaimonTypeVisitor.java           |   17 +-
 .../doris/datasource/paimon/PaimonScanParams.java  |   59 +
 .../apache/doris/datasource/paimon/PaimonUtil.java |   33 +
 .../source/PaimonRustReaderCapabilities.java       |  282 ++++
 .../datasource/paimon/source/PaimonScanNode.java   |  683 ++++++++-
 .../java/org/apache/doris/qe/SessionVariable.java  |   19 +
 .../qe/runtime/PipelineExecutionTaskBuilder.java   |    9 +-
 .../main/java/org/apache/doris/system/Backend.java |   11 +
 .../org/apache/doris/system/BackendHbResponse.java |   10 +
 .../java/org/apache/doris/system/HeartbeatMgr.java |    5 +-
 .../common/profile/ProfileCompletionStateTest.java |  217 +++
 .../doris/common/profile/ProfileManagerTest.java   |   29 +
 .../paimon/DorisToPaimonTypeVisitorTest.java       |   78 +
 .../source/PaimonRustBackendCapabilityTest.java    |  118 ++
 .../source/PaimonRustReaderCompatibilityTest.java  |  496 +++++++
 .../source/PaimonRustReaderEvolutionTest.java      |  184 +++
 .../source/PaimonRustReaderLegacySchemaTest.java   |  101 ++
 .../paimon/source/PaimonScanNodeTest.java          | 1545 +++++++++++++++++++-
 .../runtime/PipelineExecutionTaskBuilderTest.java  |   96 ++
 .../system/BackendPaimonRustCapabilityTest.java    |   70 +
 gensrc/thrift/HeartbeatService.thrift              |    1 +
 gensrc/thrift/PaloInternalService.thrift           |    3 +
 gensrc/thrift/PlanNodes.thrift                     |    7 +
 .../test_paimon_rust_reader_cast_predicates.groovy |  192 +++
 .../test_paimon_rust_reader_compatibility.groovy   |  308 ++++
 .../test_paimon_rust_reader_eq_for_null.groovy     |  555 +++++++
 ...test_paimon_rust_reader_schema_evolution.groovy |  134 ++
 .../paimon/test_paimon_rust_reader_v2.groovy       |  146 ++
 thirdparty/build-thirdparty.sh                     |  182 ++-
 thirdparty/test/paimon-build-test.py               |  279 ++++
 thirdparty/vars.sh                                 |    7 +
 54 files changed, 9840 insertions(+), 88 deletions(-)

diff --git a/be/CMakeLists.txt b/be/CMakeLists.txt
index 0da7cdd2b0f..47e69d3907e 100644
--- a/be/CMakeLists.txt
+++ b/be/CMakeLists.txt
@@ -744,7 +744,8 @@ if (GLIBC_COMPATIBILITY)
         glibc-compatibility-explicit
         glibc-compatibility
         -lm
-        lance_c)
+        lance_c
+        paimon_c)
 endif()
 
 if (NOT OS_MACOSX)
diff --git a/be/cmake/thirdparty.cmake b/be/cmake/thirdparty.cmake
index 7f518a8cb5e..b67c8e9d0aa 100644
--- a/be/cmake/thirdparty.cmake
+++ b/be/cmake/thirdparty.cmake
@@ -117,6 +117,13 @@ add_thirdparty(lance_c LIB64 NOTADD)
 if (NOT GLIBC_COMPATIBILITY)
     list(APPEND COMMON_THIRDPARTY m lance_c)
 endif()
+# libpaimon_c.a (built from paimon-rust) brings in Rust compiler_builtins that
+# would otherwise steal libm symbols. Place libm before it so the final linker
+# resolves C math symbols from the system library first.
+add_thirdparty(paimon_c LIB64 NOTADD)
+if (NOT GLIBC_COMPATIBILITY)
+    list(APPEND COMMON_THIRDPARTY m paimon_c)
+endif()
 add_thirdparty(brpc LIB64)
 add_thirdparty(rocksdb)
 add_thirdparty(cyrus-sasl LIBNAME "lib/libsasl2.a")
diff --git a/be/src/agent/heartbeat_server.cpp b/be/src/agent/heartbeat_server.cpp
index 441218f469c..5cf8c7df01d 100644
--- a/be/src/agent/heartbeat_server.cpp
+++ b/be/src/agent/heartbeat_server.cpp
@@ -95,6 +95,8 @@ void HeartbeatServer::heartbeat(THeartbeatResult& heartbeat_result,
         heartbeat_result.backend_info.__set_fragment_last_active_time(
                 get_fragment_last_active_time());
         heartbeat_result.backend_info.__set_be_mem(MemInfo::physical_mem());
+        // Reader enum support cannot be inferred from a release version during rolling upgrades.
+        heartbeat_result.backend_info.__set_supports_paimon_rust_reader(true);
     }
     watch.stop();
     if (watch.elapsed_time() > 1000L * 1000L * 1000L) {
diff --git a/be/src/exec/scan/file_scanner.cpp b/be/src/exec/scan/file_scanner.cpp
index 34cf0928553..ee5203f16f0 100644
--- a/be/src/exec/scan/file_scanner.cpp
+++ b/be/src/exec/scan/file_scanner.cpp
@@ -1117,6 +1117,25 @@ Status FileScanner::_get_next_reader() {
                 _cur_reader = std::move(mc_reader);
             } else if (range.__isset.table_format_params &&
                        range.table_format_params.table_format_type == "paimon") {
+                const auto& paimon_params = range.table_format_params.paimon_params;
+                if (paimon_params.__isset.reader_type) {
+                    switch (paimon_params.reader_type) {
+                    case TPaimonReaderType::PAIMON_JNI:
+                        break;
+                    case TPaimonReaderType::PAIMON_RUST:
+                        return Status::NotSupported(
+                                "paimon rust reader requires enable_file_scanner_v2=true; the V1 "
+                                "FileScanner no longer supports PAIMON_RUST splits");
+                    case TPaimonReaderType::PAIMON_NATIVE:
+                        return Status::InternalError(
+                                "invalid PAIMON_NATIVE reader_type for paimon FORMAT_JNI split, "
+                                "possibly caused by FE/BE protocol mismatch");
+                    default:
+                        return Status::InternalError(
+                                "unknown paimon reader_type for paimon FORMAT_JNI split, possibly "
+                                "caused by FE/BE protocol mismatch");
+                    }
+                }
                 _cur_reader = PaimonJniReader::create_unique(_file_slot_descs, _state, _profile,
                                                              range, _params);
                 init_status = ((PaimonJniReader*)(_cur_reader.get()))->init_reader();
diff --git a/be/src/exec/scan/file_scanner_v2.cpp b/be/src/exec/scan/file_scanner_v2.cpp
index 3f824b57fc1..ab055f61dc3 100644
--- a/be/src/exec/scan/file_scanner_v2.cpp
+++ b/be/src/exec/scan/file_scanner_v2.cpp
@@ -150,8 +150,19 @@ bool is_supported_jni_table_format(const TFileRangeDesc& range) {
         }
         const auto& params = range.table_format_params.paimon_params;
         if (params.__isset.reader_type) {
-            return params.reader_type == TPaimonReaderType::PAIMON_JNI &&
-                   params.__isset.paimon_split;
+            if (params.reader_type == TPaimonReaderType::PAIMON_JNI) {
+                return params.__isset.paimon_split;
+            }
+            if (params.reader_type == TPaimonReaderType::PAIMON_RUST) {
+                // The rust leaf reader inside PaimonHybridReader consumes the logical DataSplit
+                // directly. It needs the schema-json pipeline fields; a missing one fails fast
+                // in PaimonRustTableReader::prepare_split with a precise error.
+                return params.__isset.paimon_split && params.__isset.paimon_table_schema_json &&
+                       !params.paimon_table_schema_json.empty();
+            }
+            // V2 cannot pass a logical DataSplit through a raw native child without silently
+            // dropping its multi-file semantics, so PAIMON_CPP must remain on the V1 fallback.
+            return false;
         }
         if (params.__isset.paimon_split) {
             // Before reader_type was added, an encoded split unambiguously selected the Java
diff --git a/be/src/format_v2/table/paimon_reader.cpp b/be/src/format_v2/table/paimon_reader.cpp
index 9a724716c35..2ec6bde53f2 100644
--- a/be/src/format_v2/table/paimon_reader.cpp
+++ b/be/src/format_v2/table/paimon_reader.cpp
@@ -35,6 +35,7 @@
 #include "format/table/paimon_reader.h"
 #include "format_v2/column_mapper.h"
 #include "format_v2/jni/paimon_jni_reader.h"
+#include "format_v2/table/paimon_rust_table_reader.h"
 #include "format_v2/table/schema_history_util.h"
 #include "gen_cpp/PlanNodes_types.h"
 
@@ -326,15 +327,18 @@ Status PaimonHybridReader::prepare_split(const format::SplitReadOptions& options
     // timer around the first native or JNI child and double-count that initialization.
     RETURN_IF_ERROR(_ensure_current_split_reader(options));
     DORIS_CHECK(_current_split_reader != nullptr);
-    if (!_is_jni_split(options.current_range)) {
-        auto native_options = options;
-        // Legacy FE plans wrap native files in FORMAT_JNI; normalize the child contract so the
-        // physical reader does not overwrite its recovered Parquet/ORC format with that wrapper.
-        RETURN_IF_ERROR(
-                _to_file_format(options.current_range, &native_options.current_split_format));
-        return _current_split_reader->prepare_split(native_options);
-    }
-    return _current_split_reader->prepare_split(options);
+    // Rust splits (like JNI splits) stay on the serialized DataSplit path. They must not go
+    // through the native format normalization below: FE sets paimon_params.file_format on
+    // serialized splits too, so FORMAT_JNI would be rewritten to PARQUET and dispatched to the
+    // native child.
+    if (_is_jni_split(options.current_range) || _is_rust_split(options.current_range)) {
+        return _current_split_reader->prepare_split(options);
+    }
+    auto native_options = options;
+    // Legacy FE plans wrap native files in FORMAT_JNI; normalize the child contract so the
+    // physical reader does not overwrite its recovered Parquet/ORC format with that wrapper.
+    RETURN_IF_ERROR(_to_file_format(options.current_range, &native_options.current_split_format));
+    return _current_split_reader->prepare_split(native_options);
 }
 
 Status PaimonHybridReader::build_physical_splits(const FileScanSplit& source_split,
@@ -391,6 +395,12 @@ Status PaimonHybridReader::close() {
             close_status = std::move(status);
         }
     }
+    if (_rust_reader != nullptr) {
+        auto status = _rust_reader->close();
+        if (!status.ok() && close_status.ok()) {
+            close_status = std::move(status);
+        }
+    }
     _current_split_reader = nullptr;
     return close_status;
 }
@@ -403,17 +413,36 @@ void PaimonHybridReader::set_batch_size(size_t batch_size) {
     if (_jni_reader != nullptr) {
         _jni_reader->set_batch_size(_batch_size);
     }
+    if (_rust_reader != nullptr) {
+        _rust_reader->set_batch_size(_batch_size);
+    }
 }
 
 int64_t PaimonHybridReader::condition_cache_hit_count() const {
-    // Both children survive split switches, so the wrapper must publish their cumulative totals;
+    // Child readers survive split switches, so the wrapper must publish their cumulative totals;
     // returning only the active child would make FileScannerV2's monotonic delta go backwards.
     return (_native_reader == nullptr ? 0 : _native_reader->condition_cache_hit_count()) +
-           (_jni_reader == nullptr ? 0 : _jni_reader->condition_cache_hit_count());
+           (_jni_reader == nullptr ? 0 : _jni_reader->condition_cache_hit_count()) +
+           (_rust_reader == nullptr ? 0 : _rust_reader->condition_cache_hit_count());
 }
 
 Status PaimonHybridReader::_ensure_current_split_reader(const format::SplitReadOptions& options) {
-    if (_is_jni_split(options.current_range)) {
+    if (_is_rust_split(options.current_range)) {
+        DCHECK(options.current_split_format == format::FileFormat::JNI);
+        if (_rust_reader == nullptr) {
+#ifdef BE_TEST
+            if (_test_rust_reader_factory) {
+                _rust_reader = _test_rust_reader_factory();
+            } else {
+                _rust_reader = std::make_unique<format::paimon::PaimonRustTableReader>();
+            }
+#else
+            _rust_reader = std::make_unique<format::paimon::PaimonRustTableReader>();
+#endif
+            RETURN_IF_ERROR(_init_child_reader(_rust_reader.get(), format::FileFormat::JNI));
+        }
+        _current_split_reader = _rust_reader.get();
+    } else if (_is_jni_split(options.current_range)) {
         DCHECK(options.current_split_format == format::FileFormat::JNI);
         if (_jni_reader == nullptr) {
 #ifdef BE_TEST
@@ -500,6 +529,15 @@ bool PaimonHybridReader::_is_jni_split(const TFileRangeDesc& range) {
            (!params.__isset.reader_type || params.reader_type == TPaimonReaderType::PAIMON_JNI);
 }
 
+bool PaimonHybridReader::_is_rust_split(const TFileRangeDesc& range) {
+    if (!range.__isset.table_format_params || !range.table_format_params.__isset.paimon_params) {
+        return false;
+    }
+    const auto& params = range.table_format_params.paimon_params;
+    return params.__isset.paimon_split && params.__isset.reader_type &&
+           params.reader_type == TPaimonReaderType::PAIMON_RUST;
+}
+
 Status PaimonHybridReader::_to_file_format(const TFileRangeDesc& range,
                                            format::FileFormat* file_format) {
     DORIS_CHECK(file_format != nullptr);
diff --git a/be/src/format_v2/table/paimon_reader.h b/be/src/format_v2/table/paimon_reader.h
index 25c6eb75b61..f0ac448828b 100644
--- a/be/src/format_v2/table/paimon_reader.h
+++ b/be/src/format_v2/table/paimon_reader.h
@@ -89,6 +89,7 @@ public:
 
 #ifdef BE_TEST
     static bool TEST_is_jni_split(const TFileRangeDesc& range) { return _is_jni_split(range); }
+    static bool TEST_is_rust_split(const TFileRangeDesc& range) { return _is_rust_split(range); }
     static Status TEST_to_file_format(const TFileRangeDesc& range,
                                       format::FileFormat* file_format) {
         return _to_file_format(range, file_format);
@@ -96,19 +97,25 @@ public:
     void TEST_install_batch_size_children() {
         _native_reader = std::make_unique<format::TableReader>();
         _jni_reader = std::make_unique<format::TableReader>();
+        _rust_reader = std::make_unique<format::TableReader>();
     }
-    std::pair<size_t, size_t> TEST_child_batch_sizes() const {
-        return {_native_reader->TEST_batch_size(), _jni_reader->TEST_batch_size()};
+    std::tuple<size_t, size_t, size_t> TEST_child_batch_sizes() const {
+        return {_native_reader->TEST_batch_size(), _jni_reader->TEST_batch_size(),
+                _rust_reader->TEST_batch_size()};
     }
-    void TEST_set_child_condition_cache_hits(int64_t native_hits, int64_t jni_hits) {
+    void TEST_set_child_condition_cache_hits(int64_t native_hits, int64_t jni_hits,
+                                             int64_t rust_hits) {
         _native_reader->TEST_set_condition_cache_hit_count(native_hits);
         _jni_reader->TEST_set_condition_cache_hit_count(jni_hits);
+        _rust_reader->TEST_set_condition_cache_hit_count(rust_hits);
     }
     void TEST_set_child_reader_factories(
             std::function<std::unique_ptr<format::TableReader>()> native_factory,
-            std::function<std::unique_ptr<format::TableReader>()> jni_factory) {
+            std::function<std::unique_ptr<format::TableReader>()> jni_factory,
+            std::function<std::unique_ptr<format::TableReader>()> rust_factory) {
         _test_native_reader_factory = std::move(native_factory);
         _test_jni_reader_factory = std::move(jni_factory);
+        _test_rust_reader_factory = std::move(rust_factory);
     }
 #endif
 
@@ -117,14 +124,17 @@ private:
     Status _init_child_reader(format::TableReader* reader, format::FileFormat file_format);
     Status _clone_conjuncts(VExprContextSPtrs* conjuncts) const;
     static bool _is_jni_split(const TFileRangeDesc& range);
+    static bool _is_rust_split(const TFileRangeDesc& range);
     static Status _to_file_format(const TFileRangeDesc& range, format::FileFormat* file_format);
 
     std::unique_ptr<format::TableReader> _native_reader; // handle parquet/orc native splits
     std::unique_ptr<format::TableReader> _jni_reader;    // handle serialized JNI splits
+    std::unique_ptr<format::TableReader> _rust_reader;   // handle serialized rust splits
     format::TableReader* _current_split_reader = nullptr;
 #ifdef BE_TEST
     std::function<std::unique_ptr<format::TableReader>()> _test_native_reader_factory;
     std::function<std::unique_ptr<format::TableReader>()> _test_jni_reader_factory;
+    std::function<std::unique_ptr<format::TableReader>()> _test_rust_reader_factory;
 #endif
 };
 
diff --git a/be/src/format_v2/table/paimon_rust_predicate_converter.cpp b/be/src/format_v2/table/paimon_rust_predicate_converter.cpp
new file mode 100644
index 00000000000..8b1e280399e
--- /dev/null
+++ b/be/src/format_v2/table/paimon_rust_predicate_converter.cpp
@@ -0,0 +1,840 @@
+// 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.
+
+#include "format_v2/table/paimon_rust_predicate_converter.h"
+
+#include <algorithm>
+#include <cctype>
+#include <limits>
+#include <memory>
+#include <utility>
+
+#include "common/logging.h"
+#include "core/column/column_const.h"
+#include "core/column/column_nullable.h"
+#include "core/data_type/data_type.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/field.h"
+#include "core/types.h"
+#include "core/value/decimalv2_value.h"
+#include "core/value/timestamptz_value.h"
+#include "core/value/vdatetime_value.h"
+#include "exprs/runtime_filter_expr.h"
+#include "exprs/vcompound_pred.h"
+#include "exprs/vdirect_in_predicate.h"
+#include "exprs/vectorized_fn_call.h"
+#include "exprs/vexpr.h"
+#include "exprs/vin_predicate.h"
+#include "exprs/vliteral.h"
+#include "exprs/vslot_ref.h"
+
+namespace doris {
+
+namespace {
+// paimon_datum tags (see paimon.h / bindings/c/src/table.rs::datum_from_c).
+constexpr int32_t kTagBool = 0;
+constexpr int32_t kTagTinyInt = 1;
+constexpr int32_t kTagSmallInt = 2;
+constexpr int32_t kTagInt = 3;
+constexpr int32_t kTagLong = 4;
+constexpr int32_t kTagDouble = 6;
+constexpr int32_t kTagString = 7;
+constexpr int32_t kTagDate = 8;
+constexpr int32_t kTagTimestamp = 10;
+constexpr int32_t kTagDecimal = 12;
+constexpr int32_t kTagBytes = 13;
+
+// paimon decimal precision ceiling (paimon::Decimal::MAX_PRECISION).
+constexpr int32_t kPaimonDecimalMaxPrecision = 38;
+
+// RAII for an owned paimon_predicate*. and/or/not consume their inputs, so we
+// release() before handing pointers to them.
+struct predicate_deleter {
+    void operator()(paimon_predicate* p) const {
+        if (p) {
+            paimon_predicate_free(p);
+        }
+    }
+};
+using predicate_ptr = std::unique_ptr<paimon_predicate, predicate_deleter>;
+
+// RAII for an owned paimon_error*.
+struct error_deleter {
+    void operator()(paimon_error* p) const {
+        if (p) {
+            paimon_error_free(p);
+        }
+    }
+};
+using error_ptr = std::unique_ptr<paimon_error, error_deleter>;
+
+// Render a paimon_error into a string. Takes ownership of `err` via RAII so it
+// is freed on every return path. Safe to call with nullptr.
+std::string consume_predicate_error(paimon_error* err) {
+    error_ptr owned(err);
+    if (!owned) {
+        return "unknown error";
+    }
+    std::string msg;
+    if (owned->message.data != nullptr && owned->message.len > 0) {
+        msg.assign(reinterpret_cast<const char*>(owned->message.data), owned->message.len);
+    }
+    return "code=" + std::to_string(owned->code) + ", msg=" + msg;
+}
+} // namespace
+
+PaimonRustPredicateConverter::PaimonRustPredicateConverter(
+        const std::vector<std::string>& column_names, const std::vector<DataTypePtr>& column_types,
+        const paimon_table* table)
+        : _table(table) {
+    DORIS_CHECK(column_names.size() == column_types.size());
+    _columns_by_name.reserve(column_names.size());
+    for (size_t i = 0; i < column_names.size(); ++i) {
+        _columns_by_name.emplace(_normalize_name(column_names[i]),
+                                 std::make_pair(column_names[i], column_types[i]));
+    }
+    // Paimon TIMESTAMP (wall clock) is stored as epoch-millis-of-the-wall-time
+    // and the DateTimeV2 serde decodes timezone-naive arrow values in UTC, so
+    // timestamp literals convert wall->epoch in UTC. utc_time_zone() needs no
+    // tzdata lookup, so the conversion cannot silently fall back to a
+    // machine-local zone.
+    _utc_tz = cctz::utc_time_zone();
+}
+
+paimon_predicate* PaimonRustPredicateConverter::build(const VExprContextSPtrs& conjuncts) {
+    _converted_conjuncts = 0;
+    _converted_runtime_filters = 0;
+    if (_table == nullptr) {
+        return nullptr;
+    }
+    predicate_ptr result;
+    for (const auto& conjunct : conjuncts) {
+        if (!conjunct || !conjunct->root()) {
+            continue;
+        }
+        auto root = conjunct->root();
+        const bool is_runtime_filter = root->is_rf_wrapper();
+        if (root->is_rf_wrapper()) {
+            if (auto impl = root->get_impl()) {
+                // A null-aware runtime filter (an EQ_FOR_NULL join) must stay
+                // residual: its wrapper execution restores NULL probe rows to
+                // true (RuntimeFilterExpr::change_null_to_true), while the
+                // unwrapped impl — rebuilt as an ordinary IN predicate through
+                // VDirectInPredicate::get_slot_in_expr — treats NULL as
+                // not-in-set and would prune the NULL probes permanently
+                // before the join sees them. Keep the wrapper itself: it fails
+                // every dispatch below, so the conjunct stays in the residual,
+                // and it is safe to execute on selected rows, so later
+                // conjuncts keep pushing. The lance pushdown declines
+                // is_null_aware() filters for the same reason.
+                // is_null_aware() is concrete on RuntimeFilterExpr (the only
+                // class whose is_rf_wrapper() is true), so the dynamic_cast
+                // never fails in practice; the null guard keeps the unwrap for
+                // any future wrapper shape.
+                auto* rf_wrapper = dynamic_cast<RuntimeFilterExpr*>(root.get());
+                if (rf_wrapper == nullptr || !rf_wrapper->is_null_aware()) {
+                    root = impl;
+                }
+            }
+        }
+        // Preserve a safe prefix of the conjunct order: a later pushed
+        // predicate (e.g. an arrived IN runtime filter) could otherwise prune
+        // rows on which an earlier error-preserving conjunct —
+        // assert_true(...), a failing cast, ... — must still raise. The v1
+        // partition-pruning path (FileScanner::_init_runtime_filter_partition_
+        // prune_ctxs) stops at is_safe_to_execute_on_selected_rows() for the
+        // same reason, so a convertible predicate after an unsafe conjunct
+        // must not be pushed. Safe conjuncts that cannot be converted keep
+        // the old skip: they cannot raise, so pruning rows before they are
+        // evaluated as the residual never loses an error.
+        if (!_is_safe_to_push(root)) {
+            break;
+        }
+        predicate_ptr pred(_convert_expr(root));
+        if (!pred) {
+            continue;
+        }
+        ++_converted_conjuncts;
+        _converted_runtime_filters += is_runtime_filter;
+        if (!result) {
+            result = std::move(pred);
+        } else {
+            // and consumes both inputs regardless of success.
+            result.reset(paimon_predicate_and(result.release(), pred.release()));
+            if (!result) {
+                _converted_conjuncts = 0;
+                _converted_runtime_filters = 0;
+                return nullptr;
+            }
+        }
+    }
+    return result.release();
+}
+
+bool PaimonRustPredicateConverter::_is_safe_to_push(const VExprSPtr& expr) {
+    if (expr->is_safe_to_execute_on_selected_rows()) {
+        return true;
+    }
+    // VectorizedFnCall::is_safe_to_execute_on_selected_rows() admits a fixed
+    // whitelist that does not include `like`, so a like conjunct would always
+    // stop the safe prefix here and never reach _convert_like. A like call
+    // whose children are themselves safe is just as total as the whitelisted
+    // comparisons: it only compares strings, and its regex is built by escaping
+    // the pattern operand (FunctionLike::convert_like_pattern), so no scanned
+    // value can make it raise — a malformed pattern or escape operand fails
+    // at open() on every execution, regardless of which rows survive. Admit
+    // it here rather than widening the generic whitelist, which gates shared
+    // BE pushdown paths outside this reader's scope.
+    auto* fn = dynamic_cast<VectorizedFnCall*>(expr.get());
+    if (fn == nullptr || _normalize_name(fn->function_name()) != "like") {
+        return false;
+    }
+    for (uint16_t i = 0; i < fn->get_num_children(); ++i) {
+        if (!fn->get_child(i)->is_safe_to_execute_on_selected_rows()) {
+            return false;
+        }
+    }
+    return true;
+}
+
+paimon_predicate* PaimonRustPredicateConverter::_convert_expr(const VExprSPtr& expr) {
+    if (!expr) {
+        return nullptr;
+    }
+
+    // Casts are not unwrapped anywhere (predicate root included): a cast node
+    // fails every dispatch below and the conjunct stays in the Doris residual,
+    // mirroring the FE converter, which keeps casted expressions unconverted.
+    if (auto* direct_in = dynamic_cast<VDirectInPredicate*>(expr.get())) {
+        VExprSPtr in_expr;
+        if (direct_in->get_slot_in_expr(in_expr)) {
+            return _convert_in(in_expr);
+        }
+        return nullptr;
+    }
+
+    if (dynamic_cast<VInPredicate*>(expr.get()) != nullptr) {
+        return _convert_in(expr);
+    }
+
+    switch (expr->op()) {
+    case TExprOpcode::COMPOUND_AND:
+    case TExprOpcode::COMPOUND_OR:
+        return _convert_compound(expr);
+    case TExprOpcode::COMPOUND_NOT:
+        return nullptr;
+    case TExprOpcode::EQ:
+    case TExprOpcode::EQ_FOR_NULL:
+    case TExprOpcode::NE:
+    case TExprOpcode::GE:
+    case TExprOpcode::GT:
+    case TExprOpcode::LE:
+    case TExprOpcode::LT:
+        return _convert_binary(expr);
+    default:
+        break;
+    }
+
+    if (auto* fn = dynamic_cast<VectorizedFnCall*>(expr.get())) {
+        auto fn_name = _normalize_name(fn->function_name());
+        if (fn_name == "is_null_pred" || fn_name == "is_not_null_pred") {
+            return _convert_is_null(expr, fn_name);
+        }
+        if (fn_name == "like") {
+            return _convert_like(expr);
+        }
+    }
+
+    return nullptr;
+}
+
+paimon_predicate* PaimonRustPredicateConverter::_convert_compound(const VExprSPtr& expr) {
+    if (!expr || expr->get_num_children() != 2) {
+        return nullptr;
+    }
+    predicate_ptr left(_convert_expr(expr->get_child(0)));
+    if (!left) {
+        return nullptr;
+    }
+    predicate_ptr right(_convert_expr(expr->get_child(1)));
+    if (!right) {
+        return nullptr;
+    }
+
+    if (expr->op() == TExprOpcode::COMPOUND_AND) {
+        return paimon_predicate_and(left.release(), right.release());
+    }
+    if (expr->op() == TExprOpcode::COMPOUND_OR) {
+        return paimon_predicate_or(left.release(), right.release());
+    }
+    return nullptr;
+}
+
+paimon_predicate* PaimonRustPredicateConverter::_convert_in(const VExprSPtr& expr) {
+    auto* in_pred = dynamic_cast<VInPredicate*>(expr.get());
+    // Runtime-generated IN sets can exceed VExpr's 16-bit child count. A wrapped
+    // count would push only a prefix and permanently discard valid probe rows.
+    if (!in_pred || expr->children().size() < 2 ||
+        expr->children().size() > std::numeric_limits<uint16_t>::max()) {
+        return nullptr;
+    }
+    auto field_meta = _resolve_field(expr->get_child(0));
+    if (!field_meta) {
+        return nullptr;
+    }
+
+    const auto num_values = expr->get_num_children() - 1;
+    // Reserve up front so the backing strings never reallocate: each datum's
+    // str_data points into storages[i], which must stay stable.
+    std::vector<std::string> storages;
+    std::vector<paimon_datum> datums;
+    storages.reserve(num_values);
+    datums.reserve(num_values);
+    for (uint16_t i = 1; i < expr->get_num_children(); ++i) {
+        // Casted list values are rejected by _convert_literal (the same rule
+        // as the binary RHS): with debug_skip_fold_constant the cast reaches
+        // the BE un-folded, and unwrapping it would filter on the pre-cast
+        // value — in `amount IN (CAST(1.24 AS DECIMAL(10,1)))` Doris keeps
+        // the 1.2 rows while the unwrapped 1.24 push removes them. Rejecting
+        // the value rejects the whole predicate; the residual applies the
+        // cast correctly.
+        auto holder = _convert_literal(expr->get_child(i), field_meta->type);
+        if (!holder) {
+            return nullptr;
+        }
+        storages.emplace_back(std::move(holder->storage));
+        paimon_datum datum = holder->datum;
+        _bind_datum_storage(&datum, storages.back());
+        datums.emplace_back(datum);
+    }
+
+    if (datums.empty()) {
+        return nullptr;
+    }
+    if (in_pred->is_not_in()) {
+        return _take(paimon_predicate_is_not_in(_table, field_meta->column.c_str(), datums.data(),
+                                                datums.size()));
+    }
+    return _take(paimon_predicate_is_in(_table, field_meta->column.c_str(), datums.data(),
+                                        datums.size()));
+}
+
+paimon_predicate* PaimonRustPredicateConverter::_convert_binary(const VExprSPtr& expr) {
+    if (!expr || expr->get_num_children() != 2) {
+        return nullptr;
+    }
+    auto field_meta = _resolve_field(expr->get_child(0));
+    if (!field_meta) {
+        return nullptr;
+    }
+    const char* column = field_meta->column.c_str();
+
+    // Convert the RHS first so EQ_FOR_NULL (<=>) only converts when the RHS is
+    // a convertible literal, mirroring the FE converter, which rejects a
+    // non-literal RHS. A column-to-column `a <=> b` must therefore stay in the
+    // Doris residual: it has no single-column rust predicate, and pushing
+    // `a IS NULL` would wrongly discard rows like (1, 1) — rows dropped by the
+    // pushed filter cannot be recovered by the residual conjunct.
+    auto holder = _convert_literal(expr->get_child(1), field_meta->type);
+    if (!holder) {
+        return nullptr;
+    }
+
+    if (expr->op() == TExprOpcode::EQ_FOR_NULL) {
+        return _take(paimon_predicate_is_null(_table, column));
+    }
+
+    // `holder` is a local, so its storage stays put for the duration of the call.
+    _bind_datum_storage(&holder->datum, holder->storage);
+    const paimon_datum& datum = holder->datum;
+
+    switch (expr->op()) {
+    case TExprOpcode::EQ:
+        return _take(paimon_predicate_equal(_table, column, datum));
+    case TExprOpcode::NE:
+        return _take(paimon_predicate_not_equal(_table, column, datum));
+    case TExprOpcode::GE:
+        return _take(paimon_predicate_greater_or_equal(_table, column, datum));
+    case TExprOpcode::GT:
+        return _take(paimon_predicate_greater_than(_table, column, datum));
+    case TExprOpcode::LE:
+        return _take(paimon_predicate_less_or_equal(_table, column, datum));
+    case TExprOpcode::LT:
+        return _take(paimon_predicate_less_than(_table, column, datum));
+    default:
+        break;
+    }
+    return nullptr;
+}
+
+paimon_predicate* PaimonRustPredicateConverter::_convert_is_null(const VExprSPtr& expr,
+                                                                 const std::string& fn_name) {
+    if (!expr || expr->get_num_children() != 1) {
+        return nullptr;
+    }
+    auto field_meta = _resolve_field(expr->get_child(0));
+    if (!field_meta) {
+        return nullptr;
+    }
+    if (fn_name == "is_not_null_pred") {
+        return _take(paimon_predicate_is_not_null(_table, field_meta->column.c_str()));
+    }
+    return _take(paimon_predicate_is_null(_table, field_meta->column.c_str()));
+}
+
+paimon_predicate* PaimonRustPredicateConverter::_convert_like(const VExprSPtr& expr) {
+    if (!expr || expr->get_num_children() < 2) {
+        return nullptr;
+    }
+    auto field_meta = _resolve_field(expr->get_child(0));
+    if (!field_meta || !_is_string_type(field_meta->type->get_primitive_type())) {
+        return nullptr;
+    }
+
+    auto pattern_opt = _extract_string_literal(expr->get_child(1));
+    if (!pattern_opt) {
+        return nullptr;
+    }
+    const std::string& pattern = *pattern_opt;
+
+    // Doris's 3-arg like(col, pattern, escape) form: the pinned rust
+    // predicate only supports the backslash escape (its builder rejects any
+    // other escape character), so only the default-escape shape converts.
+    // The 2-arg form always carries the Doris/SQL default '\'.
+    if (expr->get_num_children() >= 3) {
+        auto escape_opt = _extract_string_literal(expr->get_child(2));
+        if (!escape_opt || escape_opt->size() != 1 || (*escape_opt)[0] != '\\') {
+            return nullptr;
+        }
+    }
+
+    // The pinned rust like implements SQL LIKE (% = any run, _ = one char,
+    // \X = literal X for any X — arrow's like kernel), and its builder
+    // optimizes `prefix%` / `%suffix` / `%mid%` shapes internally, so every
+    // pattern without a backslash matches Doris semantics exactly. The one
+    // divergence is the escape handling for a backslash before an ordinary
+    // character: Doris keeps both characters (only \%, \_ and \\ are
+    // escapes; `a\qb` matches a backslash followed by q b) while rust
+    // consumes the backslash (`a\qb` matches aqb) — pushing such a pattern
+    // would wrongly prune the Doris-matching rows before the residual can
+    // see them. Reject exactly the divergent shapes: every backslash must
+    // precede %, _ or \ (identical literal semantics on both sides) or end
+    // the pattern (literal backslash on both sides); anything else stays in
+    // the residual.
+    for (size_t i = 0; i < pattern.size(); ++i) {
+        if (pattern[i] == '\\') {
+            if (i + 1 >= pattern.size()) {
+                continue; // trailing backslash: literal on both sides
+            }
+            char next = pattern[i + 1];
+            if (next != '%' && next != '_' && next != '\\') {
+                return nullptr;
+            }
+            ++i;
+        }
+    }
+
+    paimon_datum datum {};
+    datum.tag = kTagString;
+    // `pattern` outlives the build call (it is a const ref into the caller's
+    // optional), so the datum may point straight at it.
+    _bind_datum_storage(&datum, pattern);
+    return _take(paimon_predicate_like(_table, field_meta->column.c_str(), datum, '\\'));
+}
+
+std::optional<PaimonRustPredicateConverter::FieldMeta> PaimonRustPredicateConverter::_resolve_field(
+        const VExprSPtr& expr) const {
+    if (!expr) {
+        return std::nullopt;
+    }
+    // Mirror the FE converter's convertDorisExprToSlotRef: a casted column is
+    // rejected, never unwrapped. Stripping a lossy cast changes which rows match
+    // — for a DECIMAL(10,2) column, CAST(amount AS DECIMAL(10,1)) = 1.2 keeps
+    // the row 1.24 while the unwrapped `amount = 1.2` prunes it — and rows
+    // pruned by the pushed filter cannot be recovered by the Doris residual.
+    // The conjunct stays in the residual instead.
+    auto* slot_ref = dynamic_cast<VSlotRef*>(expr.get());
+    if (!slot_ref) {
+        return std::nullopt;
+    }
+    // FileScannerV2 rewrites conjunct VSlotRefs to table global indices, so slot_id
+    // is a position, not a slot id; resolve by the carried column name against the
+    // projected-column registry instead of the desc table.
+    auto it = _columns_by_name.find(_normalize_name(slot_ref->column_name()));
+    if (it == _columns_by_name.end()) {
+        return std::nullopt;
+    }
+    const auto& [column, type] = it->second;
+    if (!_is_supported_slot_type(type->get_primitive_type())) {
+        return std::nullopt;
+    }
+    return FieldMeta {column, type};
+}
+
+std::optional<PaimonRustPredicateConverter::DatumHolder>
+PaimonRustPredicateConverter::_convert_literal(const VExprSPtr& expr,
+                                               const DataTypePtr& column_type) const {
+    // A casted literal is rejected, never unwrapped: the cast is not executed
+    // here (with constant folding disabled — debug_skip_fold_constant — it
+    // reaches the BE un-folded), so unwrapping would push the pre-cast value
+    // while the Doris residual compares against the cast result. A
+    // scale-reducing cast makes the two disagree — `amount =
+    // CAST(1.24 AS DECIMAL(10,1))` keeps a stored 1.20 row in Doris (1.24
+    // rounds down to 1.2) but the unwrapped `amount = 1.24` push prunes it, and
+    // rows pruned by the pushed filter cannot be recovered by the residual.
+    // The conjunct stays in the residual, where the cast is evaluated exactly.
+    if (expr->node_type() == TExprNodeType::CAST_EXPR) {
+        return std::nullopt;
+    }
+    auto* literal = dynamic_cast<VLiteral*>(expr.get());
+    if (!literal) {
+        return std::nullopt;
+    }
+
+    auto literal_type = remove_nullable(literal->get_data_type());
+    PrimitiveType literal_primitive = literal_type->get_primitive_type();
+    PrimitiveType slot_primitive = column_type->get_primitive_type();
+
+    ColumnPtr col = literal->get_column_ptr()->convert_to_full_column_if_const();
+    if (const auto* nullable = check_and_get_column<ColumnNullable>(*col)) {
+        if (nullable->is_null_at(0)) {
+            return std::nullopt;
+        }
+        col = nullable->get_nested_column_ptr();
+    }
+
+    Field field;
+    col->get(0, field);
+
+    DatumHolder holder;
+    paimon_datum& datum = holder.datum;
+
+    switch (slot_primitive) {
+    case TYPE_BOOLEAN: {
+        if (literal_primitive != TYPE_BOOLEAN) {
+            return std::nullopt;
+        }
+        datum.tag = kTagBool;
+        datum.int_val = static_cast<bool>(field.get<TYPE_BOOLEAN>()) ? 1 : 0;
+        return holder;
+    }
+    case TYPE_TINYINT:
+    case TYPE_SMALLINT:
+    case TYPE_INT:
+    case TYPE_BIGINT: {
+        if (!_is_integer_type(literal_primitive)) {
+            return std::nullopt;
+        }
+        int64_t value = 0;
+        switch (literal_primitive) {
+        case TYPE_TINYINT:
+            value = field.get<TYPE_TINYINT>();
+            break;
+        case TYPE_SMALLINT:
+            value = field.get<TYPE_SMALLINT>();
+            break;
+        case TYPE_INT:
+            value = field.get<TYPE_INT>();
+            break;
+        case TYPE_BIGINT:
+            value = field.get<TYPE_BIGINT>();
+            break;
+        default:
+            return std::nullopt;
+        }
+        datum.int_val = value;
+        switch (slot_primitive) {
+        case TYPE_TINYINT:
+            datum.tag = kTagTinyInt;
+            break;
+        case TYPE_SMALLINT:
+            datum.tag = kTagSmallInt;
+            break;
+        case TYPE_INT:
+            datum.tag = kTagInt;
+            break;
+        default:
+            datum.tag = kTagLong;
+            break;
+        }
+        return holder;
+    }
+    case TYPE_DOUBLE: {
+        if (literal_primitive != TYPE_DOUBLE && literal_primitive != TYPE_FLOAT) {
+            return std::nullopt;
+        }
+        datum.tag = kTagDouble;
+        datum.double_val = literal_primitive == TYPE_FLOAT
+                                   ? static_cast<double>(field.get<TYPE_FLOAT>())
+                                   : field.get<TYPE_DOUBLE>();
+        return holder;
+    }
+    case TYPE_DATE:
+    case TYPE_DATEV2: {
+        if (!_is_date_type(literal_primitive)) {
+            return std::nullopt;
+        }
+        int64_t seconds = 0;
+        if (literal_primitive == TYPE_DATE) {
+            const auto& dt = field.get<TYPE_DATE>();
+            if (!dt.is_valid_date()) {
+                return std::nullopt;
+            }
+            dt.unix_timestamp(&seconds, _utc_tz);
+        } else {
+            const auto& dt = field.get<TYPE_DATEV2>();
+            if (!dt.is_valid_date()) {
+                return std::nullopt;
+            }
+            dt.unix_timestamp(&seconds, _utc_tz);
+        }
+        datum.tag = kTagDate;
+        datum.int_val = _seconds_to_days(seconds);
+        return holder;
+    }
+    case TYPE_DATETIME:
+    case TYPE_DATETIMEV2: {
+        if (!_is_datetime_type(literal_primitive)) {
+            return std::nullopt;
+        }
+        datum.tag = kTagTimestamp;
+        if (literal_primitive == TYPE_DATETIME) {
+            const auto& dt = field.get<TYPE_DATETIME>();
+            if (!dt.is_valid_date()) {
+                return std::nullopt;
+            }
+            int64_t seconds = 0;
+            dt.unix_timestamp(&seconds, _utc_tz);
+            // No sub-second part in datetime (v1): millis only, nanos stays 0.
+            datum.int_val = seconds * 1000;
+        } else {
+            const auto& dt = field.get<TYPE_DATETIMEV2>();
+            if (!dt.is_valid_date()) {
+                return std::nullopt;
+            }
+            // ts is (seconds since epoch, the microsecond-of-second part);
+            // split it into the rust timestamp's (millis, nanos): truncating
+            // the sub-millisecond remainder would make an equality/IN predicate
+            // more selective than the original conjunct (e.g. .123456 pushed as
+            // .123000), wrongly discarding matching rows. DATETIMEV2 carries at
+            // most microseconds, and micros % 1000 * 1000 <= 999000 fits the
+            // nanos field, so the value is always representable exactly.
+            std::pair<int64_t, int64_t> ts;
+            dt.unix_timestamp(&ts, _utc_tz);
+            datum.int_val = ts.first * 1000 + ts.second / 1000;
+            datum.int_val2 = (ts.second % 1000) * 1000;
+        }
+        return holder;
+    }
+    case TYPE_VARCHAR:
+    case TYPE_STRING: {
+        if (!_is_string_type(literal_primitive)) {
+            return std::nullopt;
+        }
+        const auto& value = field.get<TYPE_STRING>();
+        datum.tag = kTagString;
+        holder.storage.assign(value.data(), value.size());
+        return holder;
+    }
+    case TYPE_DECIMALV2:
+    case TYPE_DECIMAL32:
+    case TYPE_DECIMAL64:
+    case TYPE_DECIMAL128I:
+    case TYPE_DECIMAL256: {
+        if (!_is_decimal_type(literal_primitive)) {
+            return std::nullopt;
+        }
+        int32_t precision = static_cast<int32_t>(literal_type->get_precision());
+        int32_t scale = static_cast<int32_t>(literal_type->get_scale());
+        if (precision <= 0 || precision > kPaimonDecimalMaxPrecision) {
+            return std::nullopt;
+        }
+
+        __int128 value = 0;
+        switch (literal_primitive) {
+        case TYPE_DECIMALV2:
+            value = field.get<TYPE_DECIMALV2>().value();
+            break;
+        case TYPE_DECIMAL32:
+            value = field.get<TYPE_DECIMAL32>().value;
+            break;
+        case TYPE_DECIMAL64:
+            value = field.get<TYPE_DECIMAL64>().value;
+            break;
+        case TYPE_DECIMAL128I:
+            value = field.get<TYPE_DECIMAL128I>().value;
+            break;
+        default:
+            return std::nullopt;
+        }
+        datum.tag = kTagDecimal;
+        // rust reassembles as ((int_val2 as i128) << 64) | (int_val as u64 as i128):
+        // int_val holds the low 64 bits, int_val2 the (sign-extended) high 64 bits.
+        datum.int_val = static_cast<int64_t>(static_cast<uint64_t>(value));
+        datum.int_val2 = static_cast<int64_t>(value >> 64);
+        datum.uint_val = static_cast<uint32_t>(precision);
+        datum.uint_val2 = static_cast<uint32_t>(scale);
+        return holder;
+    }
+    default:
+        break;
+    }
+    return std::nullopt;
+}
+
+std::optional<std::string> PaimonRustPredicateConverter::_extract_string_literal(
+        const VExprSPtr& expr) const {
+    // Same rule as _convert_literal: a bare string literal converts, a casted
+    // one is rejected — the cast is not executed here, so the pushed pattern
+    // must come from the literal's own value. A truncating cast (e.g. to
+    // CHAR(k)) would otherwise push a different prefix than the residual
+    // matches. Cast trees stay in the residual.
+    if (expr->node_type() == TExprNodeType::CAST_EXPR) {
+        return std::nullopt;
+    }
+    auto* literal = dynamic_cast<VLiteral*>(expr.get());
+    if (!literal) {
+        return std::nullopt;
+    }
+    auto literal_type = remove_nullable(literal->get_data_type());
+    if (!_is_string_type(literal_type->get_primitive_type())) {
+        return std::nullopt;
+    }
+
+    ColumnPtr col = literal->get_column_ptr()->convert_to_full_column_if_const();
+    if (const auto* nullable = check_and_get_column<ColumnNullable>(*col)) {
+        if (nullable->is_null_at(0)) {
+            return std::nullopt;
+        }
+        col = nullable->get_nested_column_ptr();
+    }
+    Field field;
+    col->get(0, field);
+    const auto& value = field.get<TYPE_STRING>();
+    return std::string(value.data(), value.size());
+}
+
+paimon_predicate* PaimonRustPredicateConverter::_take(paimon_result_predicate result) {
+    if (result.error != nullptr) {
+        // A single leaf failing to build is not fatal: that conjunct is simply
+        // dropped from the pushed-down filter and the engine still re-applies it.
+        // Log at WARNING so it is visible without verbose logging enabled.
+        LOG(WARNING) << "paimon-rust build predicate failed: "
+                     << consume_predicate_error(result.error);
+        return nullptr;
+    }
+    return result.predicate;
+}
+
+void PaimonRustPredicateConverter::_bind_datum_storage(paimon_datum* datum,
+                                                       const std::string& storage) {
+    if (datum->tag == kTagString || datum->tag == kTagBytes) {
+        datum->str_data = reinterpret_cast<const uint8_t*>(storage.data());
+        datum->str_len = storage.size();
+    }
+}
+
+std::string PaimonRustPredicateConverter::_normalize_name(std::string_view name) {
+    std::string out(name);
+    std::transform(out.begin(), out.end(), out.begin(),
+                   [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
+    return out;
+}
+
+int32_t PaimonRustPredicateConverter::_seconds_to_days(int64_t seconds) {
+    static constexpr int64_t kSecondsPerDay = 24 * 60 * 60;
+    int64_t days = seconds / kSecondsPerDay;
+    if (seconds < 0 && seconds % kSecondsPerDay != 0) {
+        --days;
+    }
+    return static_cast<int32_t>(days);
+}
+
+bool PaimonRustPredicateConverter::_is_integer_type(PrimitiveType type) {
+    switch (type) {
+    case TYPE_TINYINT:
+    case TYPE_SMALLINT:
+    case TYPE_INT:
+    case TYPE_BIGINT:
+        return true;
+    default:
+        return false;
+    }
+}
+
+bool PaimonRustPredicateConverter::_is_string_type(PrimitiveType type) {
+    return type == TYPE_CHAR || type == TYPE_VARCHAR || type == TYPE_STRING;
+}
+
+bool PaimonRustPredicateConverter::_is_decimal_type(PrimitiveType type) {
+    switch (type) {
+    case TYPE_DECIMALV2:
+    case TYPE_DECIMAL32:
+    case TYPE_DECIMAL64:
+    case TYPE_DECIMAL128I:
+    case TYPE_DECIMAL256:
+        return true;
+    default:
+        return false;
+    }
+}
+
+bool PaimonRustPredicateConverter::_is_date_type(PrimitiveType type) {
+    return type == TYPE_DATE || type == TYPE_DATEV2;
+}
+
+bool PaimonRustPredicateConverter::_is_datetime_type(PrimitiveType type) {
+    return type == TYPE_DATETIME || type == TYPE_DATETIMEV2;
+}
+
+bool PaimonRustPredicateConverter::_is_supported_slot_type(PrimitiveType type) {
+    switch (type) {
+    case TYPE_BOOLEAN:
+    case TYPE_TINYINT:
+    case TYPE_SMALLINT:
+    case TYPE_INT:
+    case TYPE_BIGINT:
+    case TYPE_STRING:
+    case TYPE_DATE:
+    case TYPE_DATEV2:
+        return true;
+    case TYPE_VARCHAR:
+    case TYPE_DATETIME:
+    case TYPE_DATETIMEV2:
+    case TYPE_DECIMALV2:
+    case TYPE_DECIMAL32:
+    case TYPE_DECIMAL64:
+    case TYPE_DECIMAL128I:
+    case TYPE_DECIMAL256:
+        // Rust filters files/statistics before reconciling them to the current
+        // schema. The slot cannot prove that historical VARCHAR bounds/decimal
+        // scales or source timestamp precision match Doris. A current-domain IN
+        // member could reject a row that truncation/rescaling would make equal.
+        // Keep these predicates residual until per-file equivalence is known.
+        return false;
+    case TYPE_DOUBLE:
+    case TYPE_FLOAT:
+        // Rust uses IEEE partial ordering; Doris orders NaN above finite values
+        // and equal to itself. Pruned NaN rows cannot be restored by a residual.
+        return false;
+    case TYPE_CHAR:
+    default:
+        return false;
+    }
+}
+
+} // namespace doris
diff --git a/be/src/format_v2/table/paimon_rust_predicate_converter.h b/be/src/format_v2/table/paimon_rust_predicate_converter.h
new file mode 100644
index 00000000000..76c76037b51
--- /dev/null
+++ b/be/src/format_v2/table/paimon_rust_predicate_converter.h
@@ -0,0 +1,161 @@
+// 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.
+
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <utility>
+#include <vector>
+
+#include "cctz/time_zone.h"
+#include "core/data_type/data_type.h"
+#include "core/data_type/define_primitive_type.h"
+#include "exprs/vexpr_fwd.h"
+
+// paimon-rust C bindings. This header (and the paimon_* C API) is only pulled
+// into the rust-specific translation units (the converter, the rust reader and
+// its test); it is intentionally kept out of file_scanner and other generic BE
+// code, mirroring how paimon-cpp's headers are kept isolated.
+extern "C" {
+#include "paimon_rust/paimon.h"
+}
+
+namespace doris {} // namespace doris
+
+namespace doris {
+
+// Converts Doris push-down conjuncts into a paimon-rust filter predicate.
+//
+// This mirrors PaimonPredicateConverter (the paimon-cpp variant) but targets the
+// paimon-rust C bindings. The key API differences drive the design:
+//   * paimon-cpp builds predicates standalone via PredicateBuilder using a field
+//     index + paimon::FieldType + paimon::Literal.
+//   * paimon-rust builds predicates from a live paimon_table handle and a column
+//     name (`paimon_predicate_equal(table, column, datum)`), resolving the field
+//     index and type from the table schema itself, and takes typed values as a
+//     flat tagged `paimon_datum`.
+//
+// Because the predicate functions need the opened table, the converter is
+// constructed with the table handle and must run after the table is open. The
+// returned `paimon_predicate*` is owned by the caller, who must either transfer
+// it to `paimon_read_builder_with_filter` (which consumes it) or release it via
+// `paimon_predicate_free`.
+//
+// Conversion preserves a safe prefix: conjuncts that are not safe to execute
+// on selected rows (assert_true(...), failing casts — anything that must keep
+// raising on rows a later pushed predicate could prune) stop the pushdown, so
+// no conjunct after them is pushed either; safe conjuncts that cannot be
+// represented are skipped (the engine still re-applies the full conjunct list
+// to the scanned block), so a partial filter only ever prunes a superset and
+// never changes results.
+class PaimonRustPredicateConverter {
+public:
+    // Resolve fields by column name from the supplied registry. FileScannerV2
+    // rewrites conjunct VSlotRefs to table global indices (positions, not slot
+    // ids), so a desc-table lookup must not run; the rewritten refs carry the
+    // original column name and data type instead. `column_names` should list only
+    // the columns the reader actually reads (e.g. exclude partition keys); the
+    // caller guarantees names and types are parallel.
+    PaimonRustPredicateConverter(const std::vector<std::string>& column_names,
+                                 const std::vector<DataTypePtr>& column_types,
+                                 const paimon_table* table);
+
+    // Builds an owned predicate AND-combining every convertible conjunct, or
+    // nullptr when nothing can be pushed down.
+    paimon_predicate* build(const VExprContextSPtrs& conjuncts);
+
+    // Counts top-level conjuncts in the returned filter, not its internal nodes.
+    size_t converted_conjuncts() const { return _converted_conjuncts; }
+    size_t converted_runtime_filters() const { return _converted_runtime_filters; }
+
+private:
+    struct FieldMeta {
+        // Original file column name passed verbatim to the paimon_predicate_*
+        // functions (rust resolves the field index/type from the schema by name).
+        std::string column;
+        // Column type used for literal conversion.
+        DataTypePtr type;
+    };
+
+    // A datum plus its backing byte storage. String/Bytes datums hold a borrowed
+    // pointer into `storage`; the storage must outlive the predicate build call,
+    // so callers keep the holder alive and (re)bind str_data after final placement.
+    struct DatumHolder {
+        // The `{}` value-initializes every paimon_datum field to zero. Conversion
+        // code relies on this: each branch only sets the fields its tag uses (e.g.
+        // a Timestamp sets int_val but leaves int_val2/nanos at 0), so the rest
+        // must already be zeroed. Do not drop the `{}`.
+        paimon_datum datum {};
+        std::string storage;
+    };
+
+    // Each returns an owned paimon_predicate* (transfer or free) or nullptr.
+    paimon_predicate* _convert_expr(const VExprSPtr& expr);
+    paimon_predicate* _convert_compound(const VExprSPtr& expr);
+    paimon_predicate* _convert_in(const VExprSPtr& expr);
+    paimon_predicate* _convert_binary(const VExprSPtr& expr);
+    paimon_predicate* _convert_is_null(const VExprSPtr& expr, const std::string& fn_name);
+    paimon_predicate* _convert_like(const VExprSPtr& expr);
+
+    std::optional<FieldMeta> _resolve_field(const VExprSPtr& expr) const;
+    // Whether the conjunct may join the pushed prefix: total over its input
+    // domain, on top of the generic is_safe_to_execute_on_selected_rows gate.
+    static bool _is_safe_to_push(const VExprSPtr& expr);
+    std::optional<DatumHolder> _convert_literal(const VExprSPtr& expr,
+                                                const DataTypePtr& column_type) const;
+    std::optional<std::string> _extract_string_literal(const VExprSPtr& expr) const;
+
+    // Consumes a paimon_result_predicate: frees the error (logging it) and
+    // returns the predicate, or nullptr on error.
+    static paimon_predicate* _take(paimon_result_predicate result);
+    // Point a String/Bytes datum's str_data/str_len at the given stable storage.
+    static void _bind_datum_storage(paimon_datum* datum, const std::string& storage);
+
+    static std::string _normalize_name(std::string_view name);
+    static int32_t _seconds_to_days(int64_t seconds);
+    static bool _is_integer_type(PrimitiveType type);
+    static bool _is_string_type(PrimitiveType type);
+    static bool _is_decimal_type(PrimitiveType type);
+    static bool _is_date_type(PrimitiveType type);
+    static bool _is_datetime_type(PrimitiveType type);
+    // Only push types whose file and Doris comparison domains are equivalent
+    // without per-file schema bounds, scale, or timestamp precision metadata.
+    static bool _is_supported_slot_type(PrimitiveType type);
+
+    std::unordered_map<std::string, std::pair<std::string, DataTypePtr>> _columns_by_name;
+    const paimon_table* _table = nullptr;
+    cctz::time_zone _utc_tz;
+    size_t _converted_conjuncts = 0;
+    size_t _converted_runtime_filters = 0;
+
+#ifdef BE_TEST
+    // Exposed for unit tests so they can inspect the converted paimon_datum
+    // (tag / int_val / int_val2) without a live predicate build. Declared
+    // here, after DatumHolder, because the return type is not in the
+    // complete-class context and cannot reference a member declared later.
+    std::optional<DatumHolder> TEST_convert_literal(const VExprSPtr& expr,
+                                                    const DataTypePtr& column_type) const {
+        return _convert_literal(expr, column_type);
+    }
+#endif
+};
+
+} // namespace doris
diff --git a/be/src/format_v2/table/paimon_rust_table_reader.cpp b/be/src/format_v2/table/paimon_rust_table_reader.cpp
new file mode 100644
index 00000000000..731508957bb
--- /dev/null
+++ b/be/src/format_v2/table/paimon_rust_table_reader.cpp
@@ -0,0 +1,924 @@
+// 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.
+
+#include "format_v2/table/paimon_rust_table_reader.h"
+
+#include <algorithm>
+#include <utility>
+
+#include "arrow/c/abi.h"
+#include "arrow/c/bridge.h"
+#include "arrow/record_batch.h"
+#include "arrow/result.h"
+#include "common/logging.h"
+#include "core/assert_cast.h"
+#include "core/block/block.h"
+#include "core/block/column_with_type_and_name.h"
+#include "core/column/column_const.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_string.h"
+#include "exprs/vexpr_context.h"
+#include "exprs/vliteral.h"
+#include "format_v2/column_mapper.h"
+#include "format_v2/table/paimon_rust_predicate_converter.h"
+#include "runtime/descriptors.h"
+#include "runtime/file_scan_profile.h"
+#include "runtime/runtime_state.h"
+#include "util/string_util.h"
+#include "util/timezone_utils.h"
+#include "util/url_coding.h"
+
+extern "C" {
+#include "paimon_rust/paimon.h"
+}
+
+namespace doris::format::paimon {
+
+namespace {
+constexpr const char* VALUE_KIND_FIELD = "_VALUE_KIND";
+
+// ---------------------------------------------------------------------------
+// RAII wrappers over the paimon-rust C handles. Each handle is an opaque
+// pointer owned by Rust and released by a matching paimon_*_free function.
+// ---------------------------------------------------------------------------
+#define PAIMON_OWNED(type, freefn)                \
+    struct type##_deleter {                       \
+        void operator()(paimon_##type* p) const { \
+            if (p) {                              \
+                freefn(p);                        \
+            }                                     \
+        }                                         \
+    };                                            \
+    using type##_ptr = std::unique_ptr<paimon_##type, type##_deleter>
+
+PAIMON_OWNED(table, paimon_table_free);
+PAIMON_OWNED(read_builder, paimon_read_builder_free);
+PAIMON_OWNED(plan, paimon_plan_free);
+PAIMON_OWNED(table_read, paimon_table_read_free);
+PAIMON_OWNED(record_batch_reader, paimon_record_batch_reader_free);
+PAIMON_OWNED(error, paimon_error_free);
+
+#undef PAIMON_OWNED
+
+// One Arrow batch (schema + array containers). Owning it requires a two-step
+// teardown that the unique_ptr deleters above can't express: first invoke the
+// Arrow C Data Interface `release` callback on each struct (hands buffers back
+// to the producer), then free the container structs via paimon_arrow_batch_free.
+class ArrowBatch {
+public:
+    explicit ArrowBatch(paimon_arrow_batch batch) : batch_(batch) {}
+    ~ArrowBatch() {
+        auto* schema = static_cast<ArrowSchema*>(batch_.schema);
+        auto* array = static_cast<ArrowArray*>(batch_.array);
+        if (array && array->release) {
+            array->release(array);
+        }
+        if (schema && schema->release) {
+            schema->release(schema);
+        }
+        paimon_arrow_batch_free(batch_);
+    }
+
+    ArrowBatch(const ArrowBatch&) = delete;
+    ArrowBatch& operator=(const ArrowBatch&) = delete;
+
+    ArrowSchema* schema() const { return static_cast<ArrowSchema*>(batch_.schema); }
+    ArrowArray* array() const { return static_cast<ArrowArray*>(batch_.array); }
+
+private:
+    paimon_arrow_batch batch_;
+};
+
+// Render a paimon_error into a string. Takes ownership of `err` via RAII so it
+// is freed on every return path. Safe to call with nullptr.
+std::string consume_error(paimon_error* err) {
+    error_ptr owned(err);
+    if (!owned) {
+        return "unknown error";
+    }
+    std::string msg;
+    if (owned->message.data != nullptr && owned->message.len > 0) {
+        msg.assign(reinterpret_cast<const char*>(owned->message.data), owned->message.len);
+    }
+    return "code=" + std::to_string(owned->code) + ", msg=" + msg;
+}
+
+// Render storage option KEYS for diagnostics. Values are never rendered:
+// credential keys arrive under many spellings and cases (AWS_SECRET_KEY,
+// AWS_TOKEN, fs.oss.accessKeySecret, s3.secret-key, ...), and a key-name
+// blocklist that misses one alias leaks the value into the INFO log, so
+// only the key names are printed at all.
+std::string format_options(const std::map<std::string, std::string>& options) {
+    std::string out;
+    for (const auto& kv : options) {
+        if (!out.empty()) {
+            out += ", ";
+        }
+        out += kv.first;
+    }
+    return out;
+}
+
+} // namespace
+
+// Paimon-rust handles. Order of members matters: destruction runs in reverse
+// declaration order, and the read_builder depends on the table while the arrow
+// reader depends on the whole pipeline above it. So the table MUST be declared
+// first (destroyed last) and the record batch reader last.
+struct PaimonRustTableReader::PaimonHandles {
+    table_ptr table;
+    read_builder_ptr read_builder;
+    plan_ptr plan;
+    table_read_ptr table_read;
+    record_batch_reader_ptr reader;
+};
+
+PaimonRustTableReader::PaimonRustTableReader() = default;
+
+PaimonRustTableReader::~PaimonRustTableReader() = default;
+
+Status PaimonRustTableReader::init(format::TableReadOptions&& options) {
+    RETURN_IF_ERROR(format::TableReader::init(std::move(options)));
+    {
+        // Base and derived scopes must not overlap on the same counter: RuntimeProfile timers
+        // add deltas, so nested use would double-count instead of extending lifecycle coverage.
+        SCOPED_TIMER(_profile.total_timer);
+        SCOPED_TIMER(_profile.init_timer);
+        // Materialize TIMESTAMP_LTZ in the session timezone — the same
+        // convention as the JNI reader (PaimonJniScanner reads time_zone from
+        // its scan params) and lance_reader. Timezone-naive (paimon TIMESTAMP)
+        // arrow values are decoded in UTC by the DateTimeV2 serde regardless
+        // of _ctz, so NTZ wall-clock semantics are preserved.
+        DORIS_CHECK(_runtime_state != nullptr);
+        _ctz = _runtime_state->timezone_obj();
+        if (_scanner_profile != nullptr) {
+            file_scan_profile::ensure_hierarchy(_scanner_profile);
+            _rust_total_time = ADD_CHILD_TIMER(_scanner_profile, "PaimonRustReader",
+                                               file_scan_profile::TABLE_READER);
+            _rust_open_split_time =
+                    ADD_CHILD_TIMER(_scanner_profile, "OpenSplitTime", "PaimonRustReader");
+            _rust_read_batch_time =
+                    ADD_CHILD_TIMER(_scanner_profile, "ReadBatchTime", "PaimonRustReader");
+            _rust_arrow_to_block_time =
+                    ADD_CHILD_TIMER(_scanner_profile, "ArrowToBlockTime", "PaimonRustReader");
+            _rust_predicates_input = ADD_CHILD_COUNTER(_scanner_profile, "RustPredicatesInput",
+                                                       TUnit::UNIT, "PaimonRustReader");
+            _rust_predicates_converted = ADD_CHILD_COUNTER(
+                    _scanner_profile, "RustPredicatesConverted", TUnit::UNIT, "PaimonRustReader");
+            _rust_predicates_applied = ADD_CHILD_COUNTER(_scanner_profile, "RustPredicatesApplied",
+                                                         TUnit::UNIT, "PaimonRustReader");
+            _rust_runtime_filters_input = ADD_CHILD_COUNTER(
+                    _scanner_profile, "RustRuntimeFiltersInput", TUnit::UNIT, "PaimonRustReader");
+            _rust_runtime_filters_applied = ADD_CHILD_COUNTER(
+                    _scanner_profile, "RustRuntimeFiltersApplied", TUnit::UNIT, "PaimonRustReader");
+        }
+        // Projected column name -> fixed output position, registered with both the exact and
+        // the lower-case spelling so mixed-case Rust schema output still resolves (v1
+        // semantics: exact match first, lower-case fallback on lookup).
+        _output_name_to_idx.reserve(_projected_columns.size() * 2);
+        for (size_t idx = 0; idx < _projected_columns.size(); ++idx) {
+            _output_name_to_idx.emplace(_projected_columns[idx].name, idx);
+            _output_name_to_idx.emplace(to_lower(_projected_columns[idx].name), idx);
+        }
+    }
+    return Status::OK();
+}
+
+Status PaimonRustTableReader::prepare_split(const format::SplitReadOptions& options) {
+    // EOF belongs to the previous split. Keep it set after closing that split so repeated reads
+    // are idempotent, and clear it only when a new split is explicitly prepared.
+    _close_split_reader();
+    _split_eof = false;
+    _current_range = options.current_range;
+    RETURN_IF_ERROR(format::TableReader::prepare_split(options));
+    if (current_split_pruned()) {
+        return Status::OK();
+    }
+    if (_is_table_level_count_active()) {
+        // No rust pipeline is opened; get_block emits the synthetic count rows.
+        return Status::OK();
+    }
+    RETURN_IF_ERROR(_validate_rust_split(options.current_range));
+    {
+        SCOPED_TIMER(_profile.total_timer);
+        SCOPED_TIMER(_profile.prepare_split_timer);
+        // Open may dominate the scan or fail before get_block; include it in the parent timer.
+        SCOPED_TIMER(_rust_total_time);
+        SCOPED_TIMER(_rust_open_split_time);
+        RETURN_IF_ERROR(_open_split_reader(options.current_range));
+    }
+    return Status::OK();
+}
+
+Status PaimonRustTableReader::get_block(Block* block, bool* eos) {
+    SCOPED_TIMER(_profile.total_timer);
+    SCOPED_TIMER(_profile.exec_timer);
+    SCOPED_TIMER(_rust_total_time);
+    DORIS_CHECK(block != nullptr);
+    DORIS_CHECK(eos != nullptr);
+    DORIS_CHECK(block->columns() == _projected_columns.size());
+    block->clear_column_data(_projected_columns.size());
+    *eos = false;
+
+    if (_is_table_level_count_active()) {
+        return _read_table_level_count(block, eos);
+    }
+
+    // num_splits == 0 yields an empty (but valid) stream: report EOF.
+    if (_split_eof) {
+        *eos = true;
+        return Status::OK();
+    }
+    if (!_handles || !_handles->reader) {
+        return Status::InternalError("paimon-rust reader is not initialized");
+    }
+
+    while (true) {
+        // Mirror the base TableReader cancellation contract so a cancelled query does not
+        // drain the whole split.
+        if (_io_ctx != nullptr && _io_ctx->should_stop) {
+            _split_eof = true;
+            _close_split_reader();
+            *eos = true;
+            return Status::OK();
+        }
+
+        paimon_result_next_batch next;
+        {
+            SCOPED_TIMER(_rust_read_batch_time);
+            next = paimon_record_batch_reader_next(_handles->reader.get());
+        }
+        if (next.error != nullptr) {
+            return Status::InternalError("paimon-rust read batch failed: {}",
+                                         consume_error(next.error));
+        }
+        // End of stream: both pointers are null.
+        if (next.batch.array == nullptr && next.batch.schema == nullptr) {
+            _split_eof = true;
+            _close_split_reader();
+            *eos = true;
+            return Status::OK();
+        }
+
+        // RAII: the batch's Arrow release callbacks + container free run when
+        // `batch` leaves this scope, including on any early return.
+        ArrowBatch batch(next.batch);
+
+        auto* c_array = batch.array();
+        auto* c_schema = batch.schema();
+        arrow::Result<std::shared_ptr<arrow::RecordBatch>> import_result =
+                arrow::ImportRecordBatch(c_array, c_schema);
+        if (!import_result.ok()) {
+            return Status::InternalError("failed to import paimon-rust arrow batch: {}",
+                                         import_result.status().message());
+        }
+
+        auto record_batch = std::move(import_result).ValueUnsafe();
+        const auto rows = static_cast<size_t>(record_batch->num_rows());
+        if (rows == 0) {
+            // Skip empty batches and keep draining the stream.
+            continue;
+        }
+        RETURN_IF_ERROR(_fill_block_from_record_batch(record_batch, block, rows));
+        _record_scan_rows(rows);
+        *eos = false;
+        return Status::OK();
+    }
+}
+
+Status PaimonRustTableReader::abort_split() {
+    {
+        SCOPED_TIMER(_profile.total_timer);
+        SCOPED_TIMER(_profile.close_timer);
+        _close_split_reader();
+        _split_eof = false;
+    }
+    return format::TableReader::abort_split();
+}
+
+#ifdef BE_TEST
+std::string PaimonRustTableReader::TEST_format_options(
+        const std::map<std::string, std::string>& options) {
+    return format_options(options);
+}
+
+std::map<std::string, std::string> PaimonRustTableReader::TEST_build_options(
+        TFileScanRangeParams* scan_params, const TFileRangeDesc& range) {
+    TFileScanRangeParams* previous_params = _scan_params;
+    TFileRangeDesc previous_range = _current_range;
+    _scan_params = scan_params;
+    _current_range = range;
+    std::map<std::string, std::string> options = _build_options();
+    _scan_params = previous_params;
+    _current_range = std::move(previous_range);
+    return options;
+}
+#endif
+
+Status PaimonRustTableReader::close() {
+    {
+        SCOPED_TIMER(_profile.total_timer);
+        SCOPED_TIMER(_profile.close_timer);
+        _close_split_reader();
+        _close_table();
+    }
+    return format::TableReader::close();
+}
+
+Status PaimonRustTableReader::_validate_rust_split(const TFileRangeDesc& range) const {
+    if (!range.__isset.table_format_params || !range.table_format_params.__isset.paimon_params) {
+        return Status::InternalError(
+                "missing paimon_params for paimon rust reader, possibly caused by FE/BE protocol "
+                "mismatch");
+    }
+    const auto& params = range.table_format_params.paimon_params;
+    if (!params.__isset.paimon_split || params.paimon_split.empty()) {
+        return Status::InternalError(
+                "missing paimon_split for paimon rust reader, possibly caused by FE/BE protocol "
+                "mismatch");
+    }
+    if (params.__isset.reader_type && params.reader_type != TPaimonReaderType::PAIMON_RUST) {
+        return Status::InternalError(
+                "invalid reader_type for paimon rust reader, possibly caused by FE/BE protocol "
+                "mismatch");
+    }
+    if (!_resolve_table_path(range).has_value()) {
+        return Status::InternalError(
+                "paimon-rust missing paimon_table; cannot resolve paimon table location");
+    }
+    if (!_resolve_db_name(range).has_value()) {
+        return Status::InternalError(
+                "paimon-rust missing db_name; cannot open paimon table via schema json");
+    }
+    if (!_resolve_table_name(range).has_value()) {
+        return Status::InternalError(
+                "paimon-rust missing table_name; cannot open paimon table via schema json");
+    }
+    if (!_resolve_table_schema_json(range).has_value()) {
+        return Status::InternalError(
+                "paimon-rust missing paimon_table_schema_json; cannot open paimon table via "
+                "schema json");
+    }
+    return Status::OK();
+}
+
+Status PaimonRustTableReader::_open_split_reader(const TFileRangeDesc& range) {
+    // 1. Decode the FE-planned split first so we fail fast (and without any
+    // filesystem IO) when it is missing or malformed.
+    std::string split_bytes;
+    RETURN_IF_ERROR(_decode_split_bytes(&split_bytes));
+
+    // 2. Resolve identifier + table_path + FE-supplied TableSchema JSON.
+    auto table_path = _resolve_table_path(range).value();
+    auto db_name = _resolve_db_name(range).value();
+    auto table_name = _resolve_table_name(range).value();
+    auto schema_json = _resolve_table_schema_json(range).value();
+    auto branch_opt = _resolve_branch(range);
+
+    // 3. Assemble storage options: FE-supplied paimon options + hadoop_conf +
+    // OSS/S3 → AWS_* translations. These feed FileIO only (per
+    // paimon_table_from_schema_json contract); they are NOT merged into the
+    // supplied table schema.
+    auto options = _build_options();
+
+    auto opened_table_key =
+            std::make_tuple(table_path, schema_json, db_name, table_name, branch_opt, options);
+    if (!_handles || !_handles->table || _opened_table_key != opened_table_key) {
+        // A paimon scan reads one table, so the handle is opened at most once per
+        // distinct identity (e.g. re-created after a close); splits of the same
+        // table reuse it and only rebuild the read pipeline below.
+        _close_table();
+        _handles = std::make_unique<PaimonHandles>();
+
+        std::vector<paimon_option> c_options;
+        c_options.reserve(options.size());
+        for (const auto& kv : options) {
+            c_options.push_back(paimon_option {kv.first.c_str(), kv.second.c_str()});
+        }
+
+        LOG(INFO) << "paimon-rust opening table via schema json: db=" << db_name
+                  << " table=" << table_name << " path=" << table_path
+                  << " branch=" << (branch_opt.has_value() ? branch_opt.value() : "main")
+                  << " storage_options=[" << format_options(options) << "]";
+
+        // Build the table directly from the FE-supplied schema JSON. The Rust
+        // side rejects null / empty branch, so we default to paimon's canonical
+        // "main" sentinel when FE did not set paimon_branch (i.e. the table is
+        // on the main branch — matches upstream Identifier.DEFAULT_MAIN_BRANCH).
+        const std::string& branch_str = branch_opt.has_value() ? branch_opt.value() : "main";
+        paimon_result_get_table tbl_res = paimon_table_from_schema_json(
+                table_path.c_str(), schema_json.c_str(), db_name.c_str(), table_name.c_str(),
+                branch_str.c_str(), c_options.empty() ? nullptr : c_options.data(),
+                c_options.size());
+        if (tbl_res.error != nullptr) {
+            return Status::InternalError(
+                    "paimon-rust table_from_schema_json failed: db={} table={} err={}", db_name,
+                    table_name, consume_error(tbl_res.error));
+        }
+        _handles->table.reset(tbl_res.table);
+        _opened_table_key = std::move(opened_table_key);
+    }
+
+    // 4. Build the read pipeline: read_builder -> case-insensitive -> projection.
+    // Bound the Rust Arrow allocation itself: wide rows must respect the scanner's
+    // adaptive probe size before they are materialized into a Doris block.
+    const auto batch_size = std::to_string(
+            _batch_size > 0 ? _batch_size : std::max(1, _runtime_state->batch_size()));
+    const paimon_option batch_option {"read.batch-size", batch_size.c_str()};
+    paimon_result_read_builder rb_res =
+            paimon_table_new_read_builder_with_options(_handles->table.get(), &batch_option, 1);
+    if (rb_res.error != nullptr) {
+        return Status::InternalError("paimon-rust new read builder failed: {}",
+                                     consume_error(rb_res.error));
+    }
+    _handles->read_builder.reset(rb_res.read_builder);
+
+    // Fold column casing on the Rust side so FE-normalized lowercase names
+    // resolve against tables with mixed-case column definitions.
+    if (paimon_error* case_err =
+                paimon_read_builder_with_case_sensitive(_handles->read_builder.get(), false)) {
+        return Status::InternalError("paimon-rust set case_sensitive failed: {}",
+                                     consume_error(case_err));
+    }
+
+    // Partition keys are excluded: they are materialized from split metadata
+    // (see _fill_non_arrow_columns), and paimon-rust does not emit them.
+    auto read_columns = _build_read_columns();
+    std::vector<const char*> projection;
+    projection.reserve(read_columns.size() + 1);
+    for (const auto& col : read_columns) {
+        projection.push_back(col.c_str());
+    }
+    projection.push_back(nullptr);
+    if (paimon_error* proj_err = paimon_read_builder_with_projection(_handles->read_builder.get(),
+                                                                     projection.data())) {
+        return Status::InternalError("paimon-rust set projection failed: {}",
+                                     consume_error(proj_err));
+    }
+
+    // Convert the scanner conjuncts into a paimon-rust filter and apply it.
+    RETURN_IF_ERROR(_apply_predicate());
+
+    // 5. Deserialize the FE-planned split into a one-split plan, so this
+    // scanner reads exactly the split it was assigned rather than replanning
+    // the whole table. The wire form is identical to what paimon-cpp consumes
+    // (`paimon::table::DataSplit::serialize`).
+    paimon_result_plan plan_res = paimon_plan_from_split_bytes(
+            reinterpret_cast<const uint8_t*>(split_bytes.data()), split_bytes.size());
+    if (plan_res.error != nullptr) {
+        return Status::InternalError("paimon-rust build plan failed: {}",
+                                     consume_error(plan_res.error));
+    }
+    _handles->plan.reset(plan_res.plan);
+
+    size_t num_splits = paimon_plan_num_splits(_handles->plan.get());
+    if (num_splits == 0) {
+        _split_eof = true;
+        return Status::OK();
+    }
+
+    // 6. Open the arrow stream over the plan.
+    paimon_result_new_read read_res = paimon_read_builder_new_read(_handles->read_builder.get());
+    if (read_res.error != nullptr) {
+        return Status::InternalError("paimon-rust new read failed: {}",
+                                     consume_error(read_res.error));
+    }
+    _handles->table_read.reset(read_res.read);
+
+    paimon_result_record_batch_reader rdr_res = paimon_table_read_to_arrow(
+            _handles->table_read.get(), _handles->plan.get(), /*offset=*/0, /*length=*/num_splits);
+    if (rdr_res.error != nullptr) {
+        return Status::InternalError("paimon-rust open arrow reader failed: {}",
+                                     consume_error(rdr_res.error));
+    }
+    _handles->reader.reset(rdr_res.reader);
+    return Status::OK();
+}
+
+void PaimonRustTableReader::_close_split_reader() {
+    if (!_handles) {
+        return;
+    }
+    // Reverse of the declaration order in PaimonHandles.
+    _handles->reader.reset();
+    _handles->table_read.reset();
+    _handles->plan.reset();
+    _handles->read_builder.reset();
+}
+
+void PaimonRustTableReader::_close_table() {
+    if (!_handles) {
+        return;
+    }
+    _close_split_reader();
+    _handles->table.reset();
+    _opened_table_key.reset();
+}
+
+Status PaimonRustTableReader::_apply_predicate() {
+    if (_conjuncts.empty() || !_handles || !_handles->table || !_handles->read_builder) {
+        return Status::OK();
+    }
+    if (_scanner_profile != nullptr) {
+        COUNTER_UPDATE(_rust_predicates_input, _conjuncts.size());
+        for (const auto& conjunct : _conjuncts) {
+            if (conjunct && conjunct->root() && conjunct->root()->is_rf_wrapper()) {
+                COUNTER_UPDATE(_rust_runtime_filters_input, 1);
+            }
+        }
+    }
+    LOG(INFO) << "paimon-rust predicate pushdown: " << _conjuncts.size() << " conjunct(s) input";
+    // The conjunct VSlotRefs carry table global indices (positions), so the v2
+    // converter mode resolves fields by the projected column names; partition
+    // keys are excluded because the rust reader does not read them.
+    std::vector<std::string> names;
+    std::vector<DataTypePtr> types;
+    names.reserve(_projected_columns.size());
+    types.reserve(_projected_columns.size());
+    for (const auto& col : _projected_columns) {
+        if (col.is_partition_key) {
+            continue;
+        }
+        names.push_back(col.name);
+        types.push_back(col.type);
+    }
+    PaimonRustPredicateConverter converter(names, types, _handles->table.get());
+    paimon_predicate* predicate = converter.build(_conjuncts);
+    if (_scanner_profile != nullptr) {
+        COUNTER_UPDATE(_rust_predicates_converted, converter.converted_conjuncts());
+    }
+    if (predicate == nullptr) {
+        LOG(INFO) << "paimon-rust predicate pushdown: nothing convertible, no filter applied";
+        return Status::OK();
+    }
+    // paimon_read_builder_with_filter consumes the predicate (ownership moves to
+    // the builder) on every path, so we must not free it here.
+    if (paimon_error* err =
+                paimon_read_builder_with_filter(_handles->read_builder.get(), predicate)) {
+        return Status::InternalError("paimon-rust apply filter failed: {}", consume_error(err));
+    }
+    // Count application only after the C API accepts the filter; reader timers
+    // alone cannot distinguish pushdown from Doris residual-only execution.
+    if (_scanner_profile != nullptr) {
+        COUNTER_UPDATE(_rust_predicates_applied, converter.converted_conjuncts());
+        COUNTER_UPDATE(_rust_runtime_filters_applied, converter.converted_runtime_filters());
+    }
+    LOG(INFO) << "paimon-rust predicate pushdown: applied";
+    return Status::OK();
+}
+
+Status PaimonRustTableReader::_fill_block_from_record_batch(
+        const std::shared_ptr<arrow::RecordBatch>& batch, Block* block, size_t rows) {
+    SCOPED_TIMER(_rust_arrow_to_block_time);
+    DORIS_CHECK(batch != nullptr);
+    DORIS_CHECK(block != nullptr);
+    std::unordered_set<size_t> materialized_indices;
+    materialized_indices.reserve(_projected_columns.size());
+    {
+        auto columns_guard = block->mutate_columns_scoped();
+        auto& columns = columns_guard.mutable_columns();
+        for (int c = 0; c < batch->num_columns(); ++c) {
+            const auto& field = batch->schema()->field(c);
+            if (field->name() == VALUE_KIND_FIELD) {
+                continue;
+            }
+            // Projected column names are FE-normalized to lowercase.
+            // paimon-rust's case_sensitive=false setting also case-folds column
+            // names in the schema output, so exact match works — but tolerate
+            // mixed-case Rust output by folding here as well.
+            auto it = _output_name_to_idx.find(field->name());
+            if (it == _output_name_to_idx.end()) {
+                it = _output_name_to_idx.find(to_lower(field->name()));
+            }
+            if (it == _output_name_to_idx.end()) {
+                // Skip columns that are not in the block (e.g. columns dropped by
+                // slot pruning).
+                continue;
+            }
+            const auto output_idx = it->second;
+            if (!materialized_indices.emplace(output_idx).second) {
+                return Status::InternalError("paimon-rust returned duplicate column '{}'",
+                                             field->name());
+            }
+            try {
+                RETURN_IF_ERROR(columns_guard.get_datatype_by_position(output_idx)
+                                        ->get_serde()
+                                        ->read_column_from_arrow(*columns[output_idx],
+                                                                 batch->column(c).get(), 0, rows,
+                                                                 _ctz));
+            } catch (Exception& e) {
+                return Status::InternalError("Failed to convert from arrow to block: {}", e.what());
+            }
+        }
+    }
+    // Partition columns and other projected columns absent from the arrow batch
+    // are back-filled from split metadata / defaults.
+    RETURN_IF_ERROR(_fill_non_arrow_columns(block, rows, materialized_indices));
+    // This direct Arrow path bypasses TableReader::finalize_chunk, whose last
+    // step enforces truncate_char_or_varchar_columns — without it, a column
+    // narrowed by schema evolution returns untruncated historical values.
+    RETURN_IF_ERROR(_truncate_char_or_varchar_columns(block));
+    return Status::OK();
+}
+
+Status PaimonRustTableReader::_truncate_char_or_varchar_columns(Block* block) {
+    if (_runtime_state == nullptr ||
+        !_runtime_state->query_options().truncate_char_or_varchar_columns) {
+        return Status::OK();
+    }
+    for (size_t idx = 0; idx < block->columns(); ++idx) {
+        const auto& column_type = block->get_by_position(idx).type;
+        if (column_type == nullptr) {
+            continue;
+        }
+        const auto type = remove_nullable(column_type);
+        const auto primitive = type->get_primitive_type();
+        if (primitive != TYPE_VARCHAR && primitive != TYPE_CHAR) {
+            continue;
+        }
+        const auto target_len = assert_cast<const DataTypeString*>(type.get())->len();
+        if (target_len <= 0) {
+            continue;
+        }
+        // Reuses TableReader's vectorized truncation (substring(column, 1,
+        // len)); the base variant maps through column_mapper metadata, which
+        // the direct rust path does not populate, so iterate the block's own
+        // slot-derived types here — the arrow side is always lengthless Utf8,
+        // so any bounded CHAR/VARCHAR target truncates to its declared length.
+        // Partition/default expressions can produce ColumnConst(ColumnNullable). The shared
+        // truncation helper expects a materialized nullable column, as in finalize_chunk.
+        auto& column = block->get_by_position(idx).column;
+        column = column->convert_to_full_column_if_const();
+        _truncate_char_or_varchar_column(block, idx, target_len);
+    }
+    return Status::OK();
+}
+
+Status PaimonRustTableReader::_fill_non_arrow_columns(
+        Block* block, size_t rows, const std::unordered_set<size_t>& materialized_indices) {
+    for (size_t idx = 0; idx < _projected_columns.size(); ++idx) {
+        if (materialized_indices.count(idx) != 0) {
+            continue;
+        }
+        const auto& column = _projected_columns[idx];
+        VExprContextSPtr constant_expr;
+        if (const Field* value = find_partition_value(column, _partition_values);
+            column.is_partition_key && value != nullptr) {
+            // Partition values are split constants (same materialization the
+            // TableColumnMapper builds for native readers).
+            constant_expr =
+                    VExprContext::create_shared(VLiteral::create_shared(column.type, *value));
+        } else if (column.default_expr != nullptr) {
+            constant_expr = column.default_expr;
+        } else {
+            // The column is genuinely absent from the arrow batch. Schema
+            // evolution is handled by paimon-rust itself, so reaching here means
+            // an unexpected schema drift: fill defaults so the scan remains
+            // well-defined instead of failing the query.
+            LOG(WARNING) << "paimon-rust did not return projected column '" << column.name
+                         << "'; filling with defaults";
+            auto data = column.type->create_column();
+            data->insert_many_defaults(rows);
+            block->replace_by_position(idx, std::move(data));
+            continue;
+        }
+        ColumnPtr constant_column;
+        RETURN_IF_ERROR(_materialize_constant_column(constant_expr, column.type, column.name, rows,
+                                                     &constant_column));
+        block->replace_by_position(idx, std::move(constant_column));
+    }
+    return Status::OK();
+}
+
+Status PaimonRustTableReader::_materialize_constant_column(const VExprContextSPtr& expr,
+                                                           const DataTypePtr& type,
+                                                           const std::string& name, size_t rows,
+                                                           ColumnPtr* column) {
+    DORIS_CHECK(expr != nullptr);
+    DORIS_CHECK(column != nullptr);
+    RowDescriptor row_desc;
+    RETURN_IF_ERROR(expr->prepare(_runtime_state, row_desc));
+    RETURN_IF_ERROR(expr->open(_runtime_state));
+    // Constants evaluate per input row, so a rows-sized synthetic block yields a
+    // rows-sized result for both plain literals and default expressions.
+    Block eval_block;
+    eval_block.insert({type->create_column_const_with_default_value(rows), type, name});
+    int result_column_id = -1;
+    RETURN_IF_ERROR(expr->execute(&eval_block, &result_column_id));
+    DORIS_CHECK(result_column_id >= 0);
+    ColumnPtr result_column = eval_block.get_by_position(result_column_id).column;
+    if (result_column->size() == 1 && rows > 1) {
+        result_column = ColumnConst::create(std::move(result_column), rows);
+    }
+    *column = std::move(result_column);
+    return Status::OK();
+}
+
+Status PaimonRustTableReader::_decode_split_bytes(std::string* out) const {
+    if (!_current_range.__isset.table_format_params ||
+        !_current_range.table_format_params.__isset.paimon_params ||
+        !_current_range.table_format_params.paimon_params.__isset.paimon_split) {
+        return Status::InternalError("paimon-rust missing paimon_split in scan range");
+    }
+    const auto& encoded_split = _current_range.table_format_params.paimon_params.paimon_split;
+    if (!base64_decode(encoded_split, out)) {
+        return Status::InternalError("paimon-rust base64 decode paimon_split failed");
+    }
+    if (out->empty()) {
+        return Status::InternalError("paimon-rust decoded paimon_split is empty");
+    }
+    return Status::OK();
+}
+
+std::optional<std::string> PaimonRustTableReader::_resolve_table_path(
+        const TFileRangeDesc& range) const {
+    if (range.__isset.table_format_params && range.table_format_params.__isset.paimon_params &&
+        range.table_format_params.paimon_params.__isset.paimon_table &&
+        !range.table_format_params.paimon_params.paimon_table.empty()) {
+        return range.table_format_params.paimon_params.paimon_table;
+    }
+    return std::nullopt;
+}
+
+std::optional<std::string> PaimonRustTableReader::_resolve_db_name(
+        const TFileRangeDesc& range) const {
+    if (range.__isset.table_format_params && range.table_format_params.__isset.paimon_params &&
+        range.table_format_params.paimon_params.__isset.db_name &&
+        !range.table_format_params.paimon_params.db_name.empty()) {
+        return range.table_format_params.paimon_params.db_name;
+    }
+    return std::nullopt;
+}
+
+std::optional<std::string> PaimonRustTableReader::_resolve_table_name(
+        const TFileRangeDesc& range) const {
+    if (range.__isset.table_format_params && range.table_format_params.__isset.paimon_params &&
+        range.table_format_params.paimon_params.__isset.table_name &&
+        !range.table_format_params.paimon_params.table_name.empty()) {
+        return range.table_format_params.paimon_params.table_name;
+    }
+    return std::nullopt;
+}
+
+std::optional<std::string> PaimonRustTableReader::_resolve_table_schema_json(
+        const TFileRangeDesc& range) const {
+    if (range.__isset.table_format_params && range.table_format_params.__isset.paimon_params &&
+        range.table_format_params.paimon_params.__isset.paimon_table_schema_json &&
+        !range.table_format_params.paimon_params.paimon_table_schema_json.empty()) {
+        return range.table_format_params.paimon_params.paimon_table_schema_json;
+    }
+    return std::nullopt;
+}
+
+std::optional<std::string> PaimonRustTableReader::_resolve_branch(
+        const TFileRangeDesc& range) const {
+    // FE only sets paimon_branch when the branch is not `main` (matches
+    // upstream paimon commit 742da63: null-if-DEFAULT_MAIN_BRANCH). Unset here
+    // means main-branch semantics.
+    if (range.__isset.table_format_params && range.table_format_params.__isset.paimon_params &&
+        range.table_format_params.paimon_params.__isset.paimon_branch &&
+        !range.table_format_params.paimon_params.paimon_branch.empty()) {
+        return range.table_format_params.paimon_params.paimon_branch;
+    }
+    return std::nullopt;
+}
+
+std::vector<std::string> PaimonRustTableReader::_build_read_columns() const {
+    std::vector<std::string> columns;
+    columns.reserve(_projected_columns.size());
+    for (const auto& column : _projected_columns) {
+        if (column.is_partition_key) {
+            continue;
+        }
+        columns.emplace_back(column.name);
+    }
+    return columns;
+}
+
+std::map<std::string, std::string> PaimonRustTableReader::_build_options() const {
+    std::map<std::string, std::string> options;
+    if (_scan_params && _scan_params->__isset.paimon_options &&
+        !_scan_params->paimon_options.empty()) {
+        options.insert(_scan_params->paimon_options.begin(), _scan_params->paimon_options.end());
+    } else if (_current_range.__isset.table_format_params &&
+               _current_range.table_format_params.__isset.paimon_params &&
+               _current_range.table_format_params.paimon_params.__isset.paimon_options) {
+        options.insert(_current_range.table_format_params.paimon_params.paimon_options.begin(),
+                       _current_range.table_format_params.paimon_params.paimon_options.end());
+    }
+
+    if (_scan_params && _scan_params->__isset.properties && !_scan_params->properties.empty()) {
+        for (const auto& kv : _scan_params->properties) {
+            options[kv.first] = kv.second;
+        }
+    } else if (_current_range.__isset.table_format_params &&
+               _current_range.table_format_params.__isset.paimon_params &&
+               _current_range.table_format_params.paimon_params.__isset.hadoop_conf) {
+        for (const auto& kv : _current_range.table_format_params.paimon_params.hadoop_conf) {
+            options[kv.first] = kv.second;
+        }
+    }
+
+    auto copy_if_missing = [&](const char* from_key, const char* to_key) {
+        if (options.find(to_key) != options.end()) {
+            return;
+        }
+        auto it = options.find(from_key);
+        if (it != options.end() && !it->second.empty()) {
+            options[to_key] = it->second;
+        }
+    };
+
+    // The pinned paimon-rust storage dispatcher (io/storage.rs) selects the
+    // FileIO parser from the table path's URI scheme, and libpaimon_c.a
+    // compiles in separate COS, OBS, GCS and Azdls parsers besides the OSS
+    // and S3 ones. Doris's FE normalizes every object store's credentials
+    // into the AWS_* / use_path_style aliases, which this bridge can only
+    // translate into the two key families those two parsers read:
+    //   oss://        -> fs.oss.endpoint / fs.oss.accessKeyId /
+    //                    fs.oss.accessKeySecret (+ fs.oss.securityToken STS)
+    //   s3:// / s3a:// -> paimon-java's s3.* family (access-key, secret-key,
+    //                    session.token, endpoint, region, path-style-access,
+    //                    normalized from the fs.s3a. / s3a. / s3. prefixes)
+    // The FE therefore gates the rust reader to exactly these schemes
+    // (PaimonScanNode: cosn:// / obs:// / gs:// / abfs:// warehouses fall
+    // back to JNI before the split is encoded — their parsers read the
+    // fs.cosn.userinfo.* / fs.obs.* / gcs.* / azure.* families the AWS_*
+    // aliases cannot express). The mapping here is scoped the same way so a
+    // version-skewed FE cannot smuggle AWS_* aliases into another parser's
+    // property map: a cosn:// table reaching this bridge with synthesized
+    // s3.* keys would hit the COS parser without
+    // fs.cosn.userinfo.secretId / secretKey and fail the open with a
+    // misleading auth error instead of the JNI fallback.
+    const std::string table_path = _resolve_table_path(_current_range).value_or("");
+    std::string scheme;
+    if (const auto sep = table_path.find("://"); sep != std::string::npos) {
+        scheme = to_lower(table_path.substr(0, sep));
+    }
+    if (scheme == "oss") {
+        // The OSS parser reads only the four fs.oss.* keys (and retry
+        // settings); native fs.oss.* options pass through untouched. It has
+        // no region / anonymous / assume-role handling, so nothing else is
+        // mapped for this scheme.
+        copy_if_missing("AWS_ENDPOINT", "fs.oss.endpoint");
+        copy_if_missing("AWS_ACCESS_KEY", "fs.oss.accessKeyId");
+        copy_if_missing("AWS_SECRET_KEY", "fs.oss.accessKeySecret");
+        copy_if_missing("AWS_TOKEN", "fs.oss.securityToken");
+        return options;
+    }
+    if (scheme == "s3" || scheme == "s3a") {
+        copy_if_missing("AWS_ACCESS_KEY", "s3.access-key");
+        copy_if_missing("AWS_SECRET_KEY", "s3.secret-key");
+        copy_if_missing("AWS_TOKEN", "s3.session.token");
+        copy_if_missing("AWS_ENDPOINT", "s3.endpoint");
+        copy_if_missing("AWS_REGION", "s3.region");
+        copy_if_missing("use_path_style", "s3.path-style-access");
+        // Authentication modes: the FE storage-properties channel marks anonymous
+        // access with AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS (emitted when no
+        // static credentials are configured) and assume-role with
+        // AWS_ROLE_ARN / AWS_EXTERNAL_ID (from the s3.role_arn / s3.external_id
+        // catalog properties). The crate reads s3.anonymous (skip_signature) and
+        // the s3.assumed.role.* family, so map both; without these, anonymous
+        // catalogs would consult the ambient credential chain and role-only
+        // catalogs would never assume the requested role. The remaining provider
+        // modes are ambient JVM credential chains (ENV, SYSTEM_PROPERTIES,
+        // WEB_IDENTITY, CONTAINER, INSTANCE_PROFILE) with no paimon-rust
+        // equivalent — the FE gates those away from the rust reader before the
+        // split is encoded.
+        if (options.contains("AWS_CREDENTIALS_PROVIDER_TYPE") &&
+            options.at("AWS_CREDENTIALS_PROVIDER_TYPE") == "ANONYMOUS") {
+            options["s3.anonymous"] = "true";
+        }
+        copy_if_missing("AWS_ROLE_ARN", "s3.assumed.role.arn");
+        copy_if_missing("AWS_EXTERNAL_ID", "s3.assumed.role.externalId");
+        // OSS-shaped options on an S3 warehouse (cross-protocol alias): map them
+        // to the s3.* family as well.
+        copy_if_missing("fs.oss.accessKeyId", "s3.access-key");
+        copy_if_missing("fs.oss.accessKeySecret", "s3.secret-key");
+        copy_if_missing("fs.oss.sessionToken", "s3.session.token");
+        copy_if_missing("fs.oss.endpoint", "s3.endpoint");
+        copy_if_missing("fs.oss.region", "s3.region");
+    }
+    // Every other scheme passes through untouched: hdfs:// tables read their
+    // hadoop conf as delivered, scheme-less / file:// paths need no
+    // credentials, and the COS / OBS / GCS / Azdls families are gated to JNI
+    // on the FE — synthesizing s3.* keys for any of them would be dead
+    // weight at best and a silent misconfiguration at worst.
+    return options;
+}
+
+} // namespace doris::format::paimon
diff --git a/be/src/format_v2/table/paimon_rust_table_reader.h b/be/src/format_v2/table/paimon_rust_table_reader.h
new file mode 100644
index 00000000000..0c04f2f1f04
--- /dev/null
+++ b/be/src/format_v2/table/paimon_rust_table_reader.h
@@ -0,0 +1,188 @@
+// 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.
+
+#pragma once
+
+#include <cstddef>
+#include <map>
+#include <memory>
+#include <optional>
+#include <string>
+#include <tuple>
+#include <unordered_map>
+#include <unordered_set>
+#include <vector>
+
+#include "cctz/time_zone.h"
+#include "common/status.h"
+#include "format_v2/table_reader.h"
+#include "runtime/runtime_profile.h"
+
+namespace arrow {
+class RecordBatch;
+}
+
+namespace doris::format::paimon {
+
+// Reads one FE-planned Paimon DataSplit per split through the paimon-rust C
+// bindings (libpaimon_c), using the schema-json pipeline:
+//
+//   paimon_table_from_schema_json(table_path, schema_json, db, table, branch, options)
+//     -> read_builder (case-insensitive, projection, filter)
+//     -> paimon_plan_from_split_bytes(base64-decoded FE split)
+//     -> paimon_read_builder_new_read -> paimon_table_read_to_arrow
+//     -> per-batch paimon_record_batch_reader_next (Arrow C Data Interface).
+//
+// Leaf reader inside PaimonHybridReader: a DataSplit is a logical multi-file
+// split, not a file range, so this reader does not go through
+// FileReader/TableColumnMapper and fills the table-schema output block directly
+// (same shape as LanceTableReader). Partition columns that paimon-rust does not
+// emit are materialized from the partition values captured by the TableReader
+// base class; columns with default expressions are materialized from those
+// expressions.
+class PaimonRustTableReader final : public format::TableReader {
+public:
+    // Out-of-line (in the .cpp) so TUs that construct or destroy the reader do not
+    // need the complete PaimonHandles pimpl type.
+    PaimonRustTableReader();
+    ~PaimonRustTableReader() override;
+
+    Status init(format::TableReadOptions&& options) override;
+    // Each split's Rust read builder applies the batch size stored by the base class
+    // before opening the Arrow reader.
+    Status prepare_split(const format::SplitReadOptions& options) override;
+    Status get_block(Block* block, bool* eos) override;
+    Status abort_split() override;
+    Status close() override;
+
+#ifdef BE_TEST
+    Status TEST_validate_rust_split(const TFileRangeDesc& range) const {
+        return _validate_rust_split(range);
+    }
+    Status TEST_fill_non_arrow_columns(
+            Block* block, size_t rows,
+            const std::unordered_set<size_t>& materialized_indices = {}) {
+        return _fill_non_arrow_columns(block, rows, materialized_indices);
+    }
+    // The direct-path bounded-string truncation, exposed for unit tests.
+    Status TEST_truncate_char_or_varchar_columns(Block* block) {
+        return _truncate_char_or_varchar_columns(block);
+    }
+    void TEST_set_projected_columns(std::vector<format::ColumnDefinition> columns) {
+        _projected_columns = std::move(columns);
+    }
+    void TEST_set_partition_values(std::map<std::string, Field> values) {
+        _partition_values = std::move(values);
+    }
+    // The session timezone the reader materializes TIMESTAMP_LTZ with.
+    const cctz::time_zone& TEST_ctz() const { return _ctz; }
+    // Exposed for unit tests so the keys-only option-logging contract can be
+    // checked without a live table open.
+    static std::string TEST_format_options(const std::map<std::string, std::string>& options);
+    // Storage options the reader would build for the given FE-planned scan
+    // params and split (S3 key remapping, provider modes, scheme dispatch);
+    // both must outlive the call. Exposed for unit tests.
+    std::map<std::string, std::string> TEST_build_options(TFileScanRangeParams* scan_params,
+                                                          const TFileRangeDesc& range);
+#endif
+
+private:
+    // Opaque holder for the paimon-rust C handles (defined in the .cpp so that
+    // paimon_rust/paimon.h does not leak into other translation units).
+    struct PaimonHandles;
+
+    // Fail fast (without filesystem IO) when the schema-json pipeline fields are
+    // missing from the split; the error text points at an FE/BE protocol mismatch.
+    Status _validate_rust_split(const TFileRangeDesc& range) const;
+    // Open (or reuse the cached) paimon_table and build the per-split read
+    // pipeline: read_builder -> projection -> filter -> plan -> arrow reader.
+    Status _open_split_reader(const TFileRangeDesc& range);
+    // Drop the per-split pipeline (read_builder .. record batch reader), keeping
+    // the cached table handle for the next split of the same table.
+    void _close_split_reader();
+    // Drop the cached paimon_table handle.
+    void _close_table();
+    // Convert _conjuncts (global-index VSlotRefs) into a paimon-rust filter and
+    // apply it to the read builder. Best effort: non-convertible conjuncts are
+    // dropped silently and re-applied by the scanner row-level filter.
+    Status _apply_predicate();
+    // Fill the output block from one arrow record batch: arrow columns are
+    // matched by name (exact then lower-case, v1 semantics) onto fixed projected
+    // positions, then _fill_non_arrow_columns back-fills the rest.
+    Status _fill_block_from_record_batch(const std::shared_ptr<arrow::RecordBatch>& batch,
+                                         Block* block, size_t rows);
+    // Materialize projected columns the arrow batch did not contain: partition
+    // constants, default expressions, or a default/NULL fill as a last resort.
+    Status _fill_non_arrow_columns(Block* block, size_t rows,
+                                   const std::unordered_set<size_t>& materialized_indices);
+    // Enforce truncate_char_or_varchar_columns on the direct Arrow path: the
+    // pinned rust reader maps paimon CHAR(n)/VARCHAR(n) to lengthless Arrow
+    // Utf8, so a column narrowed by schema evolution would return untruncated
+    // historical values here while the normal (finalize_chunk) path truncates.
+    // The block's own column types carry the table-side bounds (the reader
+    // builds them from the FE slot types); the arrow side is always boundless,
+    // so every bounded CHAR/VARCHAR column truncates to its declared length.
+    Status _truncate_char_or_varchar_columns(Block* block);
+    // Evaluate a constant expression (VLiteral or default_expr) on a one-row
+    // block and broadcast the result to `rows` as a ColumnConst.
+    Status _materialize_constant_column(const VExprContextSPtr& expr, const DataTypePtr& type,
+                                        const std::string& name, size_t rows, ColumnPtr* column);
+
+    std::optional<std::string> _resolve_table_path(const TFileRangeDesc& range) const;
+    std::optional<std::string> _resolve_db_name(const TFileRangeDesc& range) const;
+    std::optional<std::string> _resolve_table_name(const TFileRangeDesc& range) const;
+    std::optional<std::string> _resolve_table_schema_json(const TFileRangeDesc& range) const;
+    // Non-default branch name (unset means main branch, matching upstream
+    // paimon commit 742da63: null-if-DEFAULT_MAIN_BRANCH).
+    std::optional<std::string> _resolve_branch(const TFileRangeDesc& range) const;
+    // Base64-decode the FE-planned split into the raw serialized DataSplit bytes.
+    Status _decode_split_bytes(std::string* out) const;
+    // Projected column names the rust reader actually reads (partition keys are
+    // excluded; they are materialized from split metadata, not from arrow).
+    std::vector<std::string> _build_read_columns() const;
+    // Storage options: FE paimon_options + hadoop conf/properties, with the
+    // OSS/S3 -> AWS_* key remap (v1 semantics).
+    std::map<std::string, std::string> _build_options() const;
+
+    std::unique_ptr<PaimonHandles> _handles; // cached table + per-split pipeline
+    // Identity of the opened table so consecutive splits reuse the handle.
+    std::optional<
+            std::tuple<std::string /*table_path*/, std::string /*schema_json*/, std::string /*db*/,
+                       std::string /*table*/, std::optional<std::string> /*branch*/,
+                       std::map<std::string, std::string> /*options*/>>
+            _opened_table_key;
+    // Projected column name (exact and lower-case) -> output block position.
+    std::unordered_map<std::string, size_t> _output_name_to_idx;
+    TFileRangeDesc _current_range;
+    cctz::time_zone _ctz;
+    // EOF belongs to the current split; reset when a new split is prepared.
+    bool _split_eof = false;
+
+    // Profile counters under the "PaimonRustReader" profile group
+    // (file_scan_profile::TABLE_READER parent, mirroring JniTableReader).
+    RuntimeProfile::Counter* _rust_predicates_input = nullptr;
+    RuntimeProfile::Counter* _rust_predicates_converted = nullptr;
+    RuntimeProfile::Counter* _rust_predicates_applied = nullptr;
+    RuntimeProfile::Counter* _rust_runtime_filters_input = nullptr;
+    RuntimeProfile::Counter* _rust_runtime_filters_applied = nullptr;
+    RuntimeProfile::Counter* _rust_total_time = nullptr;
+    RuntimeProfile::Counter* _rust_open_split_time = nullptr;
+    RuntimeProfile::Counter* _rust_read_batch_time = nullptr;
+    RuntimeProfile::Counter* _rust_arrow_to_block_time = nullptr;
+};
+
+} // namespace doris::format::paimon
diff --git a/be/test/exec/scan/file_scanner_v2_test.cpp b/be/test/exec/scan/file_scanner_v2_test.cpp
index 57a17a79e5a..ef7c6ab19e2 100644
--- a/be/test/exec/scan/file_scanner_v2_test.cpp
+++ b/be/test/exec/scan/file_scanner_v2_test.cpp
@@ -911,6 +911,22 @@ TEST(FileScannerV2Test, GeneratedChildrenKeepOneGlobalRowIdSourceMapping) {
     EXPECT_EQ(first_id, second_id);
 }
 
+TFileRangeDesc paimon_rust_jni_range(bool with_schema_json = true) {
+    auto range = range_with_format("paimon", TFileFormatType::FORMAT_JNI);
+    TPaimonFileDesc paimon_params;
+    paimon_params.__set_reader_type(TPaimonReaderType::PAIMON_RUST);
+    paimon_params.__set_file_format("parquet");
+    paimon_params.__set_paimon_split("serialized-rust-split");
+    paimon_params.__set_paimon_table("/paimon/warehouse/db.db/t");
+    paimon_params.__set_db_name("db");
+    paimon_params.__set_table_name("t");
+    if (with_schema_json) {
+        paimon_params.__set_paimon_table_schema_json("{}");
+    }
+    range.table_format_params.__set_paimon_params(std::move(paimon_params));
+    return range;
+}
+
 TEST(FileScannerV2Test, JniCompatibilityShapesUseV2Scanner) {
     TQueryOptions query_options;
     query_options.__set_enable_file_scanner_v2(true);
@@ -921,6 +937,32 @@ TEST(FileScannerV2Test, JniCompatibilityShapesUseV2Scanner) {
     EXPECT_TRUE(FileScannerV2::is_supported(params, legacy_paimon_jni_range_without_reader_type()));
 }
 
+TEST(FileScannerV2Test, PaimonRustSplitUsesV2Scanner) {
+    TFileScanRangeParams params;
+    params.__set_format_type(TFileFormatType::FORMAT_JNI);
+
+    // A complete rust split (schema json + serialized split) is supported by V2.
+    EXPECT_TRUE(FileScannerV2::is_supported(params, paimon_rust_jni_range()));
+    EXPECT_TRUE(FileScannerV2::TEST_validate_scan_range(params, paimon_rust_jni_range()).ok());
+
+    // The schema-json pipeline field is required: a rust split without it cannot open the
+    // table and is rejected at validation instead of failing deep inside the reader.
+    const auto rust_range_without_schema = paimon_rust_jni_range(/*with_schema_json=*/false);
+    EXPECT_FALSE(FileScannerV2::is_supported(params, rust_range_without_schema));
+    const auto status = FileScannerV2::TEST_validate_scan_range(params, rust_range_without_schema);
+    EXPECT_TRUE(status.is<ErrorCode::NOT_IMPLEMENTED_ERROR>());
+
+    // Regression guard: PAIMON_CPP splits must remain on the V1 fallback.
+    auto cpp_range = range_with_format("paimon", TFileFormatType::FORMAT_JNI);
+    {
+        TPaimonFileDesc cpp_paimon_params;
+        cpp_paimon_params.__set_reader_type(TPaimonReaderType::PAIMON_CPP);
+        cpp_paimon_params.__set_file_format("parquet");
+        cpp_range.table_format_params.__set_paimon_params(std::move(cpp_paimon_params));
+    }
+    EXPECT_FALSE(FileScannerV2::is_supported(params, cpp_range));
+}
+
 TEST(FileScannerV2Test, IcebergDeleteSplitKeepsInitialScannerCountCap) {
     TQueryOptions query_options;
     query_options.__set_enable_file_scanner_v2(true);
diff --git a/be/test/exec/test_data/paimon_rust_batch_size/README.md b/be/test/exec/test_data/paimon_rust_batch_size/README.md
new file mode 100644
index 00000000000..b9e939b66ec
--- /dev/null
+++ b/be/test/exec/test_data/paimon_rust_batch_size/README.md
@@ -0,0 +1,32 @@
+<!--
+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.
+-->
+
+# Paimon batch-size fixture
+
+Generated with Apache Paimon 1.3.1 as an append-only Parquet table with
+`bucket=-1` and `write-only=true`. It contains 65 rows: `k` ranges from 0 to 64,
+and every `v` is 128 KiB of the ASCII character `x`.
+
+`schema/schema-0` is the table schema. `split.bin` is a Java `DataSplit`
+serialization whose bucket path is the neutral `__BUCKET__` placeholder.
+The reader test replaces that Java `writeUTF` field with the fixture's bucket
+path at runtime. No catalog, object store, or Java process is needed to read it.
+
+The fixture exercises the actual Rust Arrow reader with small batch sizes and
+checks all values across batch boundaries and repeated split preparation.
diff --git a/be/test/exec/test_data/paimon_rust_batch_size/bucket-0/data-3b6dc957-0664-4ec4-946c-9535d228957a-0.parquet b/be/test/exec/test_data/paimon_rust_batch_size/bucket-0/data-3b6dc957-0664-4ec4-946c-9535d228957a-0.parquet
new file mode 100644
index 00000000000..160939df2fb
Binary files /dev/null and b/be/test/exec/test_data/paimon_rust_batch_size/bucket-0/data-3b6dc957-0664-4ec4-946c-9535d228957a-0.parquet differ
diff --git a/be/test/exec/test_data/paimon_rust_batch_size/schema/schema-0 b/be/test/exec/test_data/paimon_rust_batch_size/schema/schema-0
new file mode 100644
index 00000000000..2c057fbb6d2
--- /dev/null
+++ b/be/test/exec/test_data/paimon_rust_batch_size/schema/schema-0
@@ -0,0 +1,22 @@
+{
+  "version" : 3,
+  "id" : 0,
+  "fields" : [ {
+    "id" : 0,
+    "name" : "k",
+    "type" : "INT"
+  }, {
+    "id" : 1,
+    "name" : "v",
+    "type" : "STRING"
+  } ],
+  "highestFieldId" : 1,
+  "partitionKeys" : [ ],
+  "primaryKeys" : [ ],
+  "options" : {
+    "bucket" : "-1",
+    "write-only" : "true",
+    "file.format" : "parquet"
+  },
+  "timeMillis" : 1790690378190
+}
\ No newline at end of file
diff --git a/be/test/exec/test_data/paimon_rust_batch_size/split.bin b/be/test/exec/test_data/paimon_rust_batch_size/split.bin
new file mode 100644
index 00000000000..10135ae00fb
Binary files /dev/null and b/be/test/exec/test_data/paimon_rust_batch_size/split.bin differ
diff --git a/be/test/format_v2/table/paimon_reader_test.cpp b/be/test/format_v2/table/paimon_reader_test.cpp
index 91900dffd99..1eaa841b507 100644
--- a/be/test/format_v2/table/paimon_reader_test.cpp
+++ b/be/test/format_v2/table/paimon_reader_test.cpp
@@ -356,6 +356,25 @@ TFileRangeDesc make_paimon_jni_range() {
     return range;
 }
 
+TFileRangeDesc make_paimon_rust_range() {
+    TFileRangeDesc range;
+    range.__set_path("/data-placeholder.parquet");
+    range.__set_format_type(TFileFormatType::FORMAT_JNI);
+    TTableFormatFileDesc table_format_params;
+    table_format_params.__set_table_format_type("paimon");
+    TPaimonFileDesc paimon_params;
+    paimon_params.__set_file_format("parquet");
+    paimon_params.__set_reader_type(TPaimonReaderType::PAIMON_RUST);
+    paimon_params.__set_paimon_split("serialized-paimon-split");
+    paimon_params.__set_paimon_table("/paimon/warehouse/db.db/t");
+    paimon_params.__set_db_name("db");
+    paimon_params.__set_table_name("t");
+    paimon_params.__set_paimon_table_schema_json("{}");
+    table_format_params.__set_paimon_params(paimon_params);
+    range.__set_table_format_params(table_format_params);
+    return range;
+}
+
 TFileRangeDesc make_legacy_paimon_native_range(TFileFormatType::type physical_format_type) {
     TFileRangeDesc range = make_paimon_native_range(physical_format_type);
     range.__set_format_type(TFileFormatType::FORMAT_JNI);
@@ -988,6 +1007,24 @@ TEST(PaimonHybridReaderTest, ClassifiesJniSplitByReaderType) {
     EXPECT_TRUE(paimon::PaimonHybridReader::TEST_is_jni_split(make_paimon_jni_range()));
 }
 
+TEST(PaimonHybridReaderTest, ClassifiesRustSplitByReaderType) {
+    // A rust split is neither a JNI split nor a native split; the dispatch order in
+    // _ensure_current_split_reader relies on the rust check running before the JNI one.
+    EXPECT_TRUE(paimon::PaimonHybridReader::TEST_is_rust_split(make_paimon_rust_range()));
+    EXPECT_FALSE(paimon::PaimonHybridReader::TEST_is_rust_split(make_paimon_jni_range()));
+    EXPECT_FALSE(paimon::PaimonHybridReader::TEST_is_rust_split(
+            make_paimon_native_range(TFileFormatType::FORMAT_PARQUET)));
+    EXPECT_FALSE(paimon::PaimonHybridReader::TEST_is_rust_split(
+            make_legacy_paimon_native_range(TFileFormatType::FORMAT_PARQUET)));
+    EXPECT_FALSE(paimon::PaimonHybridReader::TEST_is_jni_split(make_paimon_rust_range()));
+
+    // A serialized split without a reader_type stays on the legacy JNI classification.
+    TFileRangeDesc legacy_split = make_paimon_rust_range();
+    legacy_split.table_format_params.paimon_params.__isset.reader_type = false;
+    EXPECT_FALSE(paimon::PaimonHybridReader::TEST_is_rust_split(legacy_split));
+    EXPECT_TRUE(paimon::PaimonHybridReader::TEST_is_jni_split(legacy_split));
+}
+
 TEST(PaimonHybridReaderTest, ConvertsNativeSplitFileFormat) {
     FileFormat file_format;
     ASSERT_TRUE(paimon::PaimonHybridReader::TEST_to_file_format(
@@ -1028,6 +1065,7 @@ TEST(PaimonHybridReaderTest, NormalizesLegacyNativeSplitFormatBeforeChildPrepare
                 tracking_reader = child.get();
                 return child;
             },
+            [] { return std::make_unique<TableReader>(); },
             [] { return std::make_unique<TableReader>(); });
     ASSERT_TRUE(reader.init({
                                     .projected_columns = {},
@@ -1048,20 +1086,21 @@ TEST(PaimonHybridReaderTest, NormalizesLegacyNativeSplitFormatBeforeChildPrepare
     EXPECT_EQ(tracking_reader->prepared_format, FileFormat::PARQUET);
 }
 
-TEST(PaimonHybridReaderTest, AdaptiveBatchSizeReachesBothChildReaders) {
+TEST(PaimonHybridReaderTest, AdaptiveBatchSizeReachesAllChildReaders) {
     paimon::PaimonHybridReader reader;
     reader.TEST_install_batch_size_children();
     reader.set_batch_size(321);
     const auto child_batch_sizes = reader.TEST_child_batch_sizes();
-    EXPECT_EQ(child_batch_sizes.first, 321);
-    EXPECT_EQ(child_batch_sizes.second, 321);
+    EXPECT_EQ(std::get<0>(child_batch_sizes), 321);
+    EXPECT_EQ(std::get<1>(child_batch_sizes), 321);
+    EXPECT_EQ(std::get<2>(child_batch_sizes), 321);
 }
 
-TEST(PaimonHybridReaderTest, AggregatesConditionCacheHitsFromBothChildren) {
+TEST(PaimonHybridReaderTest, AggregatesConditionCacheHitsFromAllChildren) {
     paimon::PaimonHybridReader reader;
     reader.TEST_install_batch_size_children();
-    reader.TEST_set_child_condition_cache_hits(3, 5);
-    EXPECT_EQ(reader.condition_cache_hit_count(), 8);
+    reader.TEST_set_child_condition_cache_hits(3, 5, 7);
+    EXPECT_EQ(reader.condition_cache_hit_count(), 15);
 }
 
 TEST(PaimonHybridReaderTest, ForwardsLatePredicatesToActiveChild) {
@@ -1075,6 +1114,7 @@ TEST(PaimonHybridReaderTest, ForwardsLatePredicatesToActiveChild) {
                 child = tracking.get();
                 return tracking;
             },
+            [] { return std::make_unique<TableReader>(); },
             [] { return std::make_unique<TableReader>(); });
     ASSERT_TRUE(reader.init({
                                     .projected_columns = {},
@@ -1184,6 +1224,120 @@ TEST(PaimonHybridReaderTest, DispatchesNativeThenJniSplitToMatchingReader) {
     ASSERT_TRUE(reader.close().ok());
 }
 
+TEST(PaimonHybridReaderTest, DispatchesRustSplitToRustChild) {
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    paimon::PaimonHybridReader reader;
+    SplitFormatTrackingTableReader* tracking_reader = nullptr;
+    reader.TEST_set_child_reader_factories(
+            [] { return std::make_unique<TableReader>(); },
+            [] { return std::make_unique<TableReader>(); },
+            [&] {
+                auto child = std::make_unique<SplitFormatTrackingTableReader>();
+                tracking_reader = child.get();
+                return child;
+            });
+    ASSERT_TRUE(reader.init({
+                                    .projected_columns = {},
+                                    .conjuncts = {},
+                                    .format = FileFormat::JNI,
+                                    .scan_params = &scan_params,
+                                    .io_ctx = nullptr,
+                                    .runtime_state = &state,
+                                    .scanner_profile = nullptr,
+                            })
+                        .ok());
+
+    SplitReadOptions rust_split;
+    rust_split.current_range = make_paimon_rust_range();
+    rust_split.current_split_format = FileFormat::JNI;
+    ASSERT_TRUE(reader.prepare_split(rust_split).ok());
+    ASSERT_NE(tracking_reader, nullptr);
+    // A serialized rust split stays on the JNI wrapper format. FE also sets
+    // paimon_params.file_format="parquet" on serialized splits, so normalizing it
+    // to PARQUET would mis-dispatch the split to the native child.
+    EXPECT_EQ(tracking_reader->prepared_format, FileFormat::JNI);
+
+    ASSERT_TRUE(reader.close().ok());
+}
+
+TEST(PaimonHybridReaderTest, DispatchesNativeThenRustSplitToMatchingReader) {
+    RuntimeProfile profile("test_profile");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    io::FileReaderStats file_reader_stats;
+    io::FileCacheStatistics file_cache_stats;
+    auto io_ctx = make_io_context(&file_reader_stats, &file_cache_stats);
+
+    paimon::PaimonHybridReader reader;
+    // The native child is the real PaimonReader over a placeholder path; the rust
+    // child is a tracking stub, proving dispatch reaches it without native
+    // normalization.
+    SplitFormatTrackingTableReader* rust_tracking_reader = nullptr;
+    reader.TEST_set_child_reader_factories(nullptr, nullptr, [&] {
+        auto child = std::make_unique<SplitFormatTrackingTableReader>();
+        rust_tracking_reader = child.get();
+        return child;
+    });
+    ASSERT_TRUE(reader.init({
+                                    .projected_columns = {},
+                                    .conjuncts = {},
+                                    .format = FileFormat::PARQUET,
+                                    .scan_params = &scan_params,
+                                    .io_ctx = io_ctx,
+                                    .runtime_state = &state,
+                                    .scanner_profile = &profile,
+                            })
+                        .ok());
+
+    SplitReadOptions native_split;
+    native_split.current_range = make_paimon_native_range(TFileFormatType::FORMAT_PARQUET);
+    native_split.current_split_format = FileFormat::PARQUET;
+    ASSERT_TRUE(reader.prepare_split(native_split).ok());
+
+    SplitReadOptions rust_split;
+    rust_split.current_range = make_paimon_rust_range();
+    rust_split.current_split_format = FileFormat::JNI;
+    ASSERT_TRUE(reader.prepare_split(rust_split).ok());
+    ASSERT_NE(rust_tracking_reader, nullptr);
+    EXPECT_EQ(rust_tracking_reader->prepared_format, FileFormat::JNI);
+
+    ASSERT_TRUE(reader.close().ok());
+}
+
+TEST(PaimonHybridReaderTest, ForwardsLatePredicatesToRustChild) {
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto scan_params = make_local_parquet_scan_params();
+    paimon::PaimonHybridReader reader;
+    RefreshTrackingTableReader* rust_child = nullptr;
+    reader.TEST_set_child_reader_factories(
+            [] { return std::make_unique<TableReader>(); },
+            [] { return std::make_unique<TableReader>(); },
+            [&] {
+                auto tracking = std::make_unique<RefreshTrackingTableReader>();
+                rust_child = tracking.get();
+                return tracking;
+            });
+    ASSERT_TRUE(reader.init({
+                                    .projected_columns = {},
+                                    .conjuncts = {},
+                                    .format = FileFormat::JNI,
+                                    .scan_params = &scan_params,
+                                    .io_ctx = nullptr,
+                                    .runtime_state = &state,
+                                    .scanner_profile = nullptr,
+                            })
+                        .ok());
+
+    SplitReadOptions rust_split;
+    rust_split.current_range = make_paimon_rust_range();
+    rust_split.current_split_format = FileFormat::JNI;
+    ASSERT_TRUE(reader.prepare_split(rust_split).ok());
+    ASSERT_NE(rust_child, nullptr);
+    ASSERT_TRUE(reader.refresh_conjuncts({}).ok());
+    EXPECT_EQ(rust_child->refresh_count, 1);
+}
+
 TEST(PaimonHybridReaderTest, FirstNativeAndJniChildInitAreCountedOnce) {
     RuntimeProfile profile("test_profile");
     RuntimeState state {TQueryOptions(), TQueryGlobals()};
@@ -1203,6 +1357,7 @@ TEST(PaimonHybridReaderTest, FirstNativeAndJniChildInitAreCountedOnce) {
                             })
                         .ok());
     reader.TEST_set_child_reader_factories([] { return std::make_unique<SlowInitTableReader>(); },
+                                           [] { return std::make_unique<SlowInitTableReader>(); },
                                            [] { return std::make_unique<SlowInitTableReader>(); });
 
     auto* total = profile.get_counter("TableReader");
diff --git a/be/test/format_v2/table/paimon_rust_predicate_converter_test.cpp b/be/test/format_v2/table/paimon_rust_predicate_converter_test.cpp
new file mode 100644
index 00000000000..6aee77faa8d
--- /dev/null
+++ b/be/test/format_v2/table/paimon_rust_predicate_converter_test.cpp
@@ -0,0 +1,674 @@
+// 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.
+
+#include "format_v2/table/paimon_rust_predicate_converter.h"
+
+#include <gtest/gtest.h>
+
+#include <memory>
+#include <string>
+#include <utility>
+#include <vector>
+
+#include "common/status.h"
+#include "core/block/block.h"
+#include "core/column/column_vector.h"
+#include "core/data_type/data_type_date_or_datetime_v2.h"
+#include "core/data_type/data_type_decimal.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_string.h"
+#include "core/data_type/primitive_type.h"
+#include "core/field.h"
+#include "core/types.h"
+#include "core/value/vdatetime_value.h"
+#include "exprs/runtime_filter_expr.h"
+#include "exprs/vectorized_fn_call.h"
+#include "exprs/vexpr.h"
+#include "exprs/vexpr_context.h"
+#include "exprs/vin_predicate.h"
+#include "exprs/vliteral.h"
+#include "exprs/vslot_ref.h"
+
+namespace doris {
+namespace {
+
+struct table_deleter {
+    void operator()(paimon_table* table) const {
+        if (table) {
+            paimon_table_free(table);
+        }
+    }
+};
+using table_ptr = std::unique_ptr<paimon_table, table_deleter>;
+
+struct predicate_deleter {
+    void operator()(paimon_predicate* predicate) const {
+        if (predicate) {
+            paimon_predicate_free(predicate);
+        }
+    }
+};
+using predicate_ptr = std::unique_ptr<paimon_predicate, predicate_deleter>;
+
+// A binary predicate node with a hand-set opcode. The converter only inspects
+// op() and the children, never executes the expr, so execute_column_impl is a
+// stub that is never reached.
+class TestBinaryPredicate final : public VExpr {
+public:
+    TestBinaryPredicate(TExprOpcode::type opcode, VExprSPtr left, VExprSPtr right)
+            : VExpr(std::make_shared<DataTypeUInt8>(), false) {
+        _node_type = opcode == TExprOpcode::EQ_FOR_NULL ? TExprNodeType::NULL_AWARE_BINARY_PRED
+                                                        : TExprNodeType::BINARY_PRED;
+        _opcode = opcode;
+        add_child(std::move(left));
+        add_child(std::move(right));
+    }
+
+    const std::string& expr_name() const override { return _name; }
+
+    Status execute_column_impl(VExprContext* /*context*/, const Block* /*block*/,
+                               const Selector* /*selector*/, size_t /*count*/,
+                               ColumnPtr& result_column) const override {
+        result_column = ColumnUInt8::create();
+        return Status::OK();
+    }
+
+private:
+    const std::string _name = "test_binary_predicate";
+};
+
+// A cast node wrapping an operand, like the BE tree of
+// `CAST(amount AS DECIMAL(10,1)) = 1.2`. The converter only inspects
+// node_type() and the children, never executes the expr.
+class TestCastExpr final : public VExpr {
+public:
+    TestCastExpr(const DataTypePtr& target_type, VExprSPtr child) : VExpr(target_type, false) {
+        _node_type = TExprNodeType::CAST_EXPR;
+        add_child(std::move(child));
+    }
+
+    const std::string& expr_name() const override { return _name; }
+
+    Status execute_column_impl(VExprContext* /*context*/, const Block* /*block*/,
+                               const Selector* /*selector*/, size_t /*count*/,
+                               ColumnPtr& result_column) const override {
+        result_column = ColumnUInt8::create();
+        return Status::OK();
+    }
+
+private:
+    const std::string _name = "test_cast";
+};
+
+// An error-preserving conjunct like assert_true(...): executing it only on
+// rows selected by other predicates could discard the error it must raise, so
+// VExpr::is_safe_to_execute_on_selected_rows reports it unsafe (a failing
+// VCastExpr behaves the same way).
+class UnsafeExpr final : public VExpr {
+public:
+    explicit UnsafeExpr(VExprSPtr child)
+            : VExpr(make_nullable(std::make_shared<DataTypeInt32>()), false) {
+        _node_type = TExprNodeType::BINARY_PRED;
+        add_child(std::move(child));
+    }
+
+    bool is_safe_to_execute_on_selected_rows() const override { return false; }
+
+    const std::string& expr_name() const override { return _name; }
+
+    Status execute_column_impl(VExprContext* /*context*/, const Block* /*block*/,
+                               const Selector* /*selector*/, size_t /*count*/,
+                               ColumnPtr& result_column) const override {
+        result_column = ColumnUInt8::create();
+        return Status::OK();
+    }
+
+private:
+    const std::string _name = "unsafe_expr";
+};
+
+// A slot ref resolved by column name, like FileScannerV2's rewritten conjuncts.
+VExprSPtr slot_ref(const std::string& column_name, const DataTypePtr& type) {
+    return std::make_shared<VSlotRef>(0, 0, -1, type, column_name);
+}
+
+VExprSPtr slot_ref(const std::string& column_name) {
+    return slot_ref(column_name, make_nullable(std::make_shared<DataTypeInt32>()));
+}
+
+VExprSPtr int_literal(int32_t value) {
+    return VLiteral::create_shared(std::make_shared<DataTypeInt32>(),
+                                   Field::create_field<TYPE_INT>(value));
+}
+
+// A STRING column type.
+const DataTypePtr& string_type() {
+    static const auto type = make_nullable(std::make_shared<DataTypeString>());
+    return type;
+}
+
+VExprSPtr string_literal(std::string value) {
+    return VLiteral::create_shared(std::make_shared<DataTypeString>(),
+                                   Field::create_field<TYPE_STRING>(std::move(value)));
+}
+
+// A like(col, pattern[, escape]) function call. The converter only inspects
+// function_name() and the children, never executes the expr.
+VExprSPtr like_call(std::vector<VExprSPtr> children) {
+    TExprNode node;
+    node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN));
+    node.__set_node_type(TExprNodeType::FUNCTION_CALL);
+    node.__set_fn(TFunction());
+    node.fn.__set_name(TFunctionName());
+    node.fn.name.function_name = "like";
+    node.__set_is_nullable(false);
+    auto call = VectorizedFnCall::create_shared(node);
+    for (auto& child : children) {
+        call->add_child(std::move(child));
+    }
+    return call;
+}
+
+// An IN / NOT IN predicate over the given children (built through the
+// TExprNode path like VDirectInPredicate::get_slot_in_expr does, so
+// is_not_in() is initialized). The converter only inspects node_type(),
+// op() and the children.
+VExprSPtr in_predicate(bool is_not, std::vector<VExprSPtr> children) {
+    TExprNode node;
+    node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN));
+    node.__set_node_type(TExprNodeType::IN_PRED);
+    node.in_predicate.__set_is_not_in(is_not);
+    node.__set_opcode(is_not ? TExprOpcode::FILTER_NOT_IN : TExprOpcode::FILTER_IN);
+    node.__set_is_nullable(false);
+    auto predicate = VInPredicate::create_shared(node);
+    for (auto& child : children) {
+        predicate->add_child(std::move(child));
+    }
+    return predicate;
+}
+
+// 2024-01-01 00:00:00.<microsecond> as a DATETIMEV2(6) literal.
+const DataTypePtr& datetimev2_type() {
+    static const auto type = make_nullable(std::make_shared<DataTypeDateTimeV2>(6));
+    return type;
+}
+
+VExprSPtr datetimev2_literal(uint32_t microsecond) {
+    DateV2Value<DateTimeV2ValueType> value;
+    value.unchecked_set_time(2024, 1, 1, 0, 0, 0, microsecond);
+    return VLiteral::create_shared(std::make_shared<DataTypeDateTimeV2>(6),
+                                   Field::create_field<TYPE_DATETIMEV2>(value));
+}
+
+// A DECIMAL(10,2) column type.
+const DataTypePtr& decimal_type() {
+    static const auto type = make_nullable(std::make_shared<DataTypeDecimal64>(10, 2));
+    return type;
+}
+
+// A DECIMAL(10,2) literal, e.g. decimal_literal(1, 24) is 1.24.
+VExprSPtr decimal_literal(int64_t integer, int64_t fraction) {
+    return VLiteral::create_shared(
+            std::make_shared<DataTypeDecimal64>(10, 2),
+            Field::create_field<TYPE_DECIMAL64>(Decimal64::from_int_frac(integer, fraction, 2)));
+}
+
+VExprSPtr runtime_in_filter(VExprSPtr predicate) {
+    TExprNode node;
+    node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN));
+    node.__set_node_type(TExprNodeType::IN_PRED);
+    node.__set_is_nullable(true);
+    return RuntimeFilterExpr::create_shared(node, std::move(predicate), 0.0, false, 1);
+}
+
+// A DOUBLE column type.
+const DataTypePtr& double_type() {
+    static const auto type = make_nullable(std::make_shared<DataTypeFloat64>());
+    return type;
+}
+
+} // namespace
+
+// Pins the EQ_FOR_NULL (<=>) decision matrix of the converter. Semantics are
+// aligned with the FE PaimonPredicateConverter, which only converts a binary
+// conjunct when the RHS is a convertible literal: a column-to-column
+// `a <=> b` must NOT be pushed down — the old code pushed `a IS NULL` and
+// wrongly discarded rows like (1, 1), which the residual conjunct cannot
+// recover.
+class PaimonRustPredicateConverterTest : public testing::Test {
+protected:
+    void SetUp() override {
+        // The table handle is built in memory from the schema JSON (the same
+        // format as an on-disk schema/schema-N file): building predicates only
+        // resolves fields against the schema, so the path never needs to exist.
+        // NOTE: the custom `json` delimiter matters — the schema contains
+        // `TIMESTAMP(6)"`, whose `)"` would terminate a plain R"(...)" early.
+        static constexpr const char* kSchemaJson =
+                R"json({"version":3,"id":0,"fields":[{"id":0,"name":"a","type":"INT"},)json"
+                R"json({"id":1,"name":"b","type":"INT"},{"id":2,"name":"ts",)json"
+                R"json("type":"TIMESTAMP(6)"},{"id":3,"name":"amount",)json"
+                R"json("type":"DECIMAL(10, 2)"},{"id":4,"name":"d",)json"
+                R"json("type":"DOUBLE"},{"id":5,"name":"s","type":"STRING"}],)json"
+                R"json("highestFieldId":5,"partitionKeys":[],"primaryKeys":[],)json"
+                R"json("options":{},"timeMillis":0})json";
+        auto result = paimon_table_from_schema_json("/tmp/paimon_rust_predicate_converter_test",
+                                                    kSchemaJson, "db", "t", "main", nullptr, 0);
+        if (result.error != nullptr) {
+            std::string message;
+            if (result.error->message.data != nullptr && result.error->message.len > 0) {
+                message.assign(reinterpret_cast<const char*>(result.error->message.data),
+                               result.error->message.len);
+            }
+            const int32_t code = result.error->code;
+            paimon_error_free(result.error);
+            FAIL() << "paimon_table_from_schema_json failed: code=" << code << ", msg=" << message;
+        }
+        _table.reset(result.table);
+
+        _column_names = {"a", "b", "ts", "amount", "d", "s"};
+        _column_types = {make_nullable(std::make_shared<DataTypeInt32>()),
+                         make_nullable(std::make_shared<DataTypeInt32>()),
+                         datetimev2_type(),
+                         decimal_type(),
+                         double_type(),
+                         string_type()};
+    }
+
+    // Runs one conjunct through a fresh converter; a null return means the
+    // conjunct was rejected (left to the Doris residual).
+    predicate_ptr push(TExprOpcode::type opcode, VExprSPtr left, VExprSPtr right) {
+        PaimonRustPredicateConverter converter(_column_names, _column_types, _table.get());
+        VExprSPtr root =
+                std::make_shared<TestBinaryPredicate>(opcode, std::move(left), std::move(right));
+        VExprContextSPtrs conjuncts {VExprContext::create_shared(std::move(root))};
+        return predicate_ptr(converter.build(conjuncts));
+    }
+
+    // Runs one pre-built expr (e.g. an IN predicate) through a fresh
+    // converter; a null return means the conjunct was rejected.
+    predicate_ptr push_expr(VExprSPtr expr) {
+        PaimonRustPredicateConverter converter(_column_names, _column_types, _table.get());
+        VExprContextSPtrs conjuncts {VExprContext::create_shared(std::move(expr))};
+        return predicate_ptr(converter.build(conjuncts));
+    }
+
+    // Converts a literal expr against a column type so tests can inspect the
+    // resulting paimon_datum (tag / int_val / int_val2).
+    auto convert_literal(const VExprSPtr& expr, const DataTypePtr& column_type) {
+        PaimonRustPredicateConverter converter(_column_names, _column_types, _table.get());
+        return converter.TEST_convert_literal(expr, column_type);
+    }
+
+    // Base epoch millis of 2024-01-01 00:00:00 through the same conversion;
+    // the absolute value depends on the converter's timezone resolution.
+    int64_t converted_base_millis() {
+        auto holder = convert_literal(datetimev2_literal(0), datetimev2_type());
+        EXPECT_TRUE(holder.has_value());
+        return holder->datum.int_val;
+    }
+
+    table_ptr _table;
+    std::vector<std::string> _column_names;
+    std::vector<DataTypePtr> _column_types;
+};
+
+TEST_F(PaimonRustPredicateConverterTest, EqForNullColumnToColumnIsNotPushed) {
+    // The P1 case: `a <=> b` must stay in the residual. Pushing `a IS NULL`
+    // would keep only the (NULL, NULL) row and drop (1, 1) forever.
+    // (The literal forms `a <=> NULL` / `a <=> 1` are not covered: FE's
+    // NullSafeEqualToEqual rewrite turns them into IS NULL / plain equality
+    // before they ever reach a BE converter, so EQ_FOR_NULL arriving here is
+    // always column-to-column.)
+    EXPECT_EQ(push(TExprOpcode::EQ_FOR_NULL, slot_ref("a"), slot_ref("b")).get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, EqLiteralIsPushed) {
+    // Sanity: plain equality still converts.
+    auto predicate = push(TExprOpcode::EQ, slot_ref("a"), int_literal(1));
+    EXPECT_NE(predicate.get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, UnknownColumnIsNotPushed) {
+    // A slot outside the reader's projected columns cannot be resolved.
+    EXPECT_EQ(push(TExprOpcode::EQ, slot_ref("nope"), int_literal(1)).get(), nullptr);
+}
+
+// ---- pushdown stops at unsafe conjuncts (safe-prefix rule) ----
+
+TEST_F(PaimonRustPredicateConverterTest, UnsafeConjunctStopsLaterPredicatesFromBeingPushed) {
+    // The review's P1 case: an error-preserving conjunct (assert_true(...),
+    // a failing cast) that precedes a convertible predicate — in practice an
+    // arrived IN runtime filter, modeled here by a plain equality — must stop
+    // the pushdown. Pushing the later predicate would let rust prune the row
+    // on which the unsafe conjunct must still raise.
+    PaimonRustPredicateConverter converter(_column_names, _column_types, _table.get());
+    VExprContextSPtrs conjuncts;
+    conjuncts.push_back(VExprContext::create_shared(std::make_shared<UnsafeExpr>(slot_ref("a"))));
+    conjuncts.push_back(VExprContext::create_shared(
+            std::make_shared<TestBinaryPredicate>(TExprOpcode::EQ, slot_ref("b"), int_literal(1))));
+
+    predicate_ptr predicate(converter.build(conjuncts));
+    EXPECT_EQ(predicate.get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, SafePrefixBeforeUnsafeConjunctIsStillPushed) {
+    // The prefix itself stays pushable: the convertible conjunct before the
+    // unsafe one is pushed, and the unsafe one only blocks everything after
+    // it (the later equality must not be pushed).
+    PaimonRustPredicateConverter converter(_column_names, _column_types, _table.get());
+    VExprContextSPtrs conjuncts;
+    conjuncts.push_back(VExprContext::create_shared(
+            std::make_shared<TestBinaryPredicate>(TExprOpcode::EQ, slot_ref("a"), int_literal(1))));
+    conjuncts.push_back(VExprContext::create_shared(std::make_shared<UnsafeExpr>(slot_ref("b"))));
+    conjuncts.push_back(VExprContext::create_shared(
+            std::make_shared<TestBinaryPredicate>(TExprOpcode::EQ, slot_ref("b"), int_literal(2))));
+
+    predicate_ptr predicate(converter.build(conjuncts));
+    EXPECT_NE(predicate.get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, DoubleColumnPredicatesAreNotPushed) {
+    // Doris defines NaN as equal to itself and greater than every finite
+    // value, but the pinned rust evaluator compares doubles with
+    // f64::partial_cmp (IEEE partial ordering: NaN unordered, NaN != NaN), so
+    // a pushed `d > 1.0` would drop a stored NaN row Doris retains and a
+    // pushed IN (NaN) would reject it — rows pruned by the rust filter cannot
+    // be recovered by the residual. DOUBLE pushdown is skipped entirely
+    // (paimon-java's JNI path is unaffected: its CompareUtils goes through
+    // Double.compareTo, which is Doris's total ordering).
+    EXPECT_EQ(push(TExprOpcode::EQ, slot_ref("d", double_type()), int_literal(1)).get(), nullptr);
+    EXPECT_EQ(push(TExprOpcode::GT, slot_ref("d", double_type()), int_literal(1)).get(), nullptr);
+}
+
+// ---- IN list values must be bare literals (FE doInPredicate parity) ----
+
+TEST_F(PaimonRustPredicateConverterTest, InListIsPushed) {
+    // Sanity: an IN over bare literals still converts, so the rejections
+    // below come from the casted children, not from IN handling.
+    auto predicate =
+            push_expr(in_predicate(false, {slot_ref("a"), int_literal(1), int_literal(2)}));
+    EXPECT_NE(predicate.get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, OversizedRuntimeInFilterStaysResidual) {
+    // Arrived runtime filters can exceed the 16-bit expression child count even
+    // though SQL IN lists normally cannot. Never push only a wrapped prefix.
+    for (size_t values : {65534, 65535, 65536, 65537}) {
+        SCOPED_TRACE(values);
+        std::vector<VExprSPtr> children {slot_ref("a")};
+        for (size_t i = 0; i < values; ++i) {
+            children.emplace_back(int_literal(static_cast<int32_t>(i)));
+        }
+        auto predicate = push_expr(runtime_in_filter(in_predicate(false, std::move(children))));
+        EXPECT_EQ(predicate != nullptr, values < 65535);
+    }
+}
+
+TEST_F(PaimonRustPredicateConverterTest, NullAwareRuntimeFilterStaysResidual) {
+    // A null-aware runtime filter (arrived from an EQ_FOR_NULL join) restores
+    // NULL probe rows to true in its residual execution
+    // (RuntimeFilterExpr::change_null_to_true). Unwrapping it to the ordinary
+    // IN set — rebuilt through VDirectInPredicate::get_slot_in_expr — treats
+    // NULL as not-in-set and would prune the NULL probes permanently before
+    // the join sees them. The wrapper is kept instead: it fails every
+    // dispatch, so the conjunct stays in the residual, while a non-null-aware
+    // wrapper still unwraps and pushes its impl (the arrived-IN case).
+    // VExpr(node) derives the wrapper's data type from the node's type
+    // descriptor, so it needs a valid one (a bare node aborts in the thrift
+    // type-vector walk).
+    TExprNode node;
+    node.__set_type(create_type_desc(PrimitiveType::TYPE_BOOLEAN));
+    node.__set_node_type(TExprNodeType::IN_PRED);
+    node.__set_is_nullable(true);
+    VExprSPtr null_aware_rf = RuntimeFilterExpr::create_shared(
+            node, in_predicate(false, {slot_ref("a"), int_literal(1), int_literal(2)}), 0.0,
+            /*null_aware=*/true, 1);
+    EXPECT_EQ(push_expr(std::move(null_aware_rf)).get(), nullptr);
+
+    VExprSPtr plain_rf = RuntimeFilterExpr::create_shared(
+            node, in_predicate(false, {slot_ref("a"), int_literal(1), int_literal(2)}), 0.0,
+            /*null_aware=*/false, 2);
+    EXPECT_NE(push_expr(std::move(plain_rf)).get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, CastedInListValueIsNotPushed) {
+    // The review's P1 case (IN form): with debug_skip_fold_constant=true, a
+    // cast in the IN list reaches the BE un-folded. Unwrapping it (the old
+    // one-level cast rule from _convert_literal) would push the pre-cast
+    // value, but Doris compares against the cast result — `amount IN
+    // (CAST(1.24 AS DECIMAL(10,1)))` must compare against 1.2, and the
+    // unwrapped 1.24 push would permanently remove the 1.2 rows. _convert_literal
+    // rejects the casted value, which rejects the whole predicate; the
+    // residual applies the cast correctly.
+    auto casted = std::make_shared<TestCastExpr>(make_nullable(std::make_shared<DataTypeInt64>()),
+                                                 int_literal(1));
+    EXPECT_EQ(push_expr(in_predicate(false, {slot_ref("a"), int_literal(1), std::move(casted)}))
+                      .get(),
+              nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, CastedNotInListValueIsNotPushed) {
+    // Same rule for NOT IN: rejecting the push keeps both readers on the
+    // Doris residual, where the cast is evaluated correctly.
+    auto casted = std::make_shared<TestCastExpr>(make_nullable(std::make_shared<DataTypeInt64>()),
+                                                 int_literal(1));
+    EXPECT_EQ(push_expr(in_predicate(true, {slot_ref("a"), std::move(casted)})).get(), nullptr);
+}
+
+// The sub-millisecond remainder must land in int_val2 (nanos) instead of being
+// truncated — the rust datum holds (millis = int_val, nanos = int_val2) and
+// timestamp comparison includes both. A truncated conversion would turn
+// `ts = '2024-01-01 00:00:00.123456'` into `.123000` and wrongly drop the
+// matching rows — the runtime-filter IN scenario from the review. (The base
+// epoch millis depend on the converter's timezone resolution, so expectations
+// are expressed relative to a whole-second literal converted the same way.)
+TEST_F(PaimonRustPredicateConverterTest, TimestampV2FractionalMicrosArePreserved) {
+    const int64_t base = converted_base_millis();
+    auto holder = convert_literal(datetimev2_literal(123456), datetimev2_type());
+    ASSERT_TRUE(holder.has_value());
+    // paimon_datum tag 10 = Timestamp (see paimon.h).
+    EXPECT_EQ(holder->datum.tag, 10);
+    EXPECT_EQ(holder->datum.int_val, base + 123);
+    EXPECT_EQ(holder->datum.int_val2, 456000);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, TimestampV2WholeMillisLeaveNanosZero) {
+    const int64_t base = converted_base_millis();
+    auto holder = convert_literal(datetimev2_literal(123000), datetimev2_type());
+    ASSERT_TRUE(holder.has_value());
+    EXPECT_EQ(holder->datum.int_val, base + 123);
+    EXPECT_EQ(holder->datum.int_val2, 0);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, TimestampV2MaxFractionIsPreserved) {
+    const int64_t base = converted_base_millis();
+    auto holder = convert_literal(datetimev2_literal(999999), datetimev2_type());
+    ASSERT_TRUE(holder.has_value());
+    EXPECT_EQ(holder->datum.int_val, base + 999);
+    EXPECT_EQ(holder->datum.int_val2, 999000);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, TimestampPredicatesWithoutSourcePrecisionStayResidual) {
+    // A DATETIMEV2 slot does not reveal whether its source was TIMESTAMP(9).
+    auto predicate =
+            push(TExprOpcode::EQ, slot_ref("ts", datetimev2_type()), datetimev2_literal(123456));
+    EXPECT_EQ(predicate.get(), nullptr);
+    EXPECT_EQ(push_expr(runtime_in_filter(in_predicate(false, {slot_ref("ts", datetimev2_type()),
+                                                               datetimev2_literal(123456)})))
+                      .get(),
+              nullptr);
+}
+
+// ---- casted operands are rejected, mirroring the FE converter ----
+
+TEST_F(PaimonRustPredicateConverterTest, CastedColumnEqIsNotPushed) {
+    // `CAST(a AS BIGINT) = 1` must stay in the residual: a cast on the column
+    // is not equivalence-preserving in general, so like the FE converter's
+    // convertDorisExprToSlotRef the cast is not unwrapped. (The old code
+    // stripped it via expr_without_cast and pushed against the raw column.)
+    auto casted = std::make_shared<TestCastExpr>(make_nullable(std::make_shared<DataTypeInt64>()),
+                                                 slot_ref("a"));
+    EXPECT_EQ(push(TExprOpcode::EQ, std::move(casted), int_literal(1)).get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, DecimalScaleCastColumnIsNotPushed) {
+    // The review's P1 case: for a DECIMAL(10,2) column holding 1.24,
+    // `CAST(amount AS DECIMAL(10,1)) = 1.2` must retain the row, but pushing
+    // the unwrapped `amount = 1.2` against the original column prunes it. The
+    // rust filter does not save us either: it accepts decimal literals with
+    // different scales and compares their mathematical values (1.24 != 1.2),
+    // so the unsafe predicate is not rejected as a type mismatch. The conjunct
+    // must stay in the Doris residual.
+    auto casted = std::make_shared<TestCastExpr>(
+            make_nullable(std::make_shared<DataTypeDecimal64>(10, 1)),
+            slot_ref("amount", decimal_type()));
+    EXPECT_EQ(push(TExprOpcode::EQ, std::move(casted), decimal_literal(1, 2)).get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, DecimalPredicatesWithoutFileScaleStayResidual) {
+    // Historical file statistics may still have a larger scale than this slot.
+    auto predicate =
+            push(TExprOpcode::EQ, slot_ref("amount", decimal_type()), decimal_literal(1, 24));
+    EXPECT_EQ(predicate.get(), nullptr);
+    EXPECT_EQ(push_expr(runtime_in_filter(in_predicate(false, {slot_ref("amount", decimal_type()),
+                                                               decimal_literal(1, 23)})))
+                      .get(),
+              nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, SingleCastLiteralRhsIsNotPushed) {
+    // The review's P1 case (binary form): a single cast wrapping a literal on
+    // the RHS must stay in the residual. Unwrapping it (the old one-level cast
+    // rule from _convert_literal) pushed the pre-cast value, but Doris compares
+    // against the cast result, so with debug_skip_fold_constant=true the rust
+    // filter and the residual disagree on lossy casts. The cast is not
+    // evaluated here, so the predicate cannot be built from the raw literal.
+    auto casted = std::make_shared<TestCastExpr>(make_nullable(std::make_shared<DataTypeInt64>()),
+                                                 int_literal(1));
+    EXPECT_EQ(push(TExprOpcode::EQ, slot_ref("a"), std::move(casted)).get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, DecimalScaleCastLiteralRhsIsNotPushed) {
+    // The differential case: `amount = CAST(1.24 AS DECIMAL(10,1))` on a
+    // DECIMAL(10,2) column. The cast evaluates to 1.2, so Doris's residual
+    // keeps a stored 1.20 row; the unwrapped literal 1.24 pushed against the
+    // raw column would prune it before the residual runs, and pruned rows
+    // cannot be recovered. The rust filter does not reject this as a type
+    // mismatch either — it accepts decimals of different scales and compares
+    // their mathematical values (1.24 != 1.20) — so the only safe conversion
+    // is none: the conjunct stays in the residual.
+    auto casted = std::make_shared<TestCastExpr>(
+            make_nullable(std::make_shared<DataTypeDecimal64>(10, 1)), decimal_literal(1, 24));
+    EXPECT_EQ(push(TExprOpcode::EQ, slot_ref("amount", decimal_type()), std::move(casted)).get(),
+              nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, NestedCastLiteralIsNotPushed) {
+    // `CAST(CAST(1 AS BIGINT) AS DOUBLE)`: cast trees are rejected at the
+    // outer level now — the old code stripped all cast levels, and the
+    // interim code unwrapped exactly one.
+    auto inner = std::make_shared<TestCastExpr>(make_nullable(std::make_shared<DataTypeInt64>()),
+                                                int_literal(1));
+    auto outer = std::make_shared<TestCastExpr>(make_nullable(std::make_shared<DataTypeFloat64>()),
+                                                std::move(inner));
+    EXPECT_EQ(push(TExprOpcode::EQ, slot_ref("a"), std::move(outer)).get(), nullptr);
+}
+
+// ---- LIKE patterns must be bare literals ----
+
+TEST_F(PaimonRustPredicateConverterTest, LikeIsPushed) {
+    // Sanity: a plain `s LIKE 'abc%'` converts, so the rejections below come
+    // from the pattern / escape handling, not from LIKE support itself.
+    auto predicate = push_expr(like_call({slot_ref("s", string_type()), string_literal("abc%")}));
+    EXPECT_NE(predicate.get(), nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, CastedLikePatternIsNotPushed) {
+    // A cast wrapping the pattern must stay in the residual: _extract_string_
+    // literal does not execute the cast, so the pushed pattern would be the
+    // pre-cast value while the Doris residual matches the cast result (a
+    // truncating cast like CAST('abc%' AS CHAR(2)) changes the prefix). Same
+    // rule as _convert_literal for the binary RHS and IN-list values.
+    auto casted = std::make_shared<TestCastExpr>(make_nullable(std::make_shared<DataTypeString>()),
+                                                 string_literal("abc%"));
+    EXPECT_EQ(push_expr(like_call({slot_ref("s", string_type()), std::move(casted)})).get(),
+              nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, LikeWithDivergentEscapeIsNotPushed) {
+    // A backslash before an ordinary character diverges between Doris and the
+    // pinned rust like (Doris keeps both characters, rust consumes the
+    // backslash), so such patterns stay in the residual. Escapes before %, _
+    // and \\ (and a trailing backslash) have identical semantics on both
+    // sides and still convert.
+    EXPECT_EQ(push_expr(like_call({slot_ref("s", string_type()), string_literal("a\\qb%")})).get(),
+              nullptr);
+    EXPECT_NE(push_expr(like_call({slot_ref("s", string_type()), string_literal("a\\%b")})).get(),
+              nullptr);
+    EXPECT_NE(push_expr(like_call({slot_ref("s", string_type()), string_literal("a\\\\b_")})).get(),
+              nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, LikeWithNonDefaultEscapeIsNotPushed) {
+    // The 3-arg like(col, pattern, escape) form only converts for the Doris
+    // default escape '\'; the rust predicate builder rejects anything else.
+    EXPECT_NE(push_expr(like_call({slot_ref("s", string_type()), string_literal("abc%"),
+                                   string_literal("\\")}))
+                      .get(),
+              nullptr);
+    EXPECT_EQ(push_expr(like_call({slot_ref("s", string_type()), string_literal("a!b"),
+                                   string_literal("!")}))
+                      .get(),
+              nullptr);
+    // A casted escape char is rejected like a casted pattern.
+    auto casted_escape = std::make_shared<TestCastExpr>(
+            make_nullable(std::make_shared<DataTypeString>()), string_literal("\\"));
+    EXPECT_EQ(push_expr(like_call({slot_ref("s", string_type()), string_literal("abc%"),
+                                   std::move(casted_escape)}))
+                      .get(),
+              nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, BoundedStringPredicatesStayResidual) {
+    // An old physical value can be longer than the current VARCHAR bound.
+    auto bounded = make_nullable(std::make_shared<DataTypeString>(3, TYPE_VARCHAR));
+    _column_types.back() = bounded;
+    EXPECT_EQ(push(TExprOpcode::EQ, slot_ref("s", bounded), string_literal("abc")).get(), nullptr);
+    EXPECT_EQ(push_expr(runtime_in_filter(in_predicate(
+                                false, {slot_ref("s", bounded), string_literal("abc")})))
+                      .get(),
+              nullptr);
+}
+
+TEST_F(PaimonRustPredicateConverterTest, ConversionCountersExcludeResidualsAndReset) {
+    auto rf = runtime_in_filter(in_predicate(false, {slot_ref("a"), int_literal(1)}));
+    auto timestamp =
+            in_predicate(false, {slot_ref("ts", datetimev2_type()), datetimev2_literal(123456)});
+    PaimonRustPredicateConverter converter(_column_names, _column_types, _table.get());
+    predicate_ptr predicate(converter.build(
+            {VExprContext::create_shared(rf), VExprContext::create_shared(timestamp)}));
+    ASSERT_NE(predicate.get(), nullptr);
+    EXPECT_EQ(converter.converted_conjuncts(), 1);
+    EXPECT_EQ(converter.converted_runtime_filters(), 1);
+    predicate.reset(converter.build({VExprContext::create_shared(timestamp)}));
+    EXPECT_EQ(predicate.get(), nullptr);
+    EXPECT_EQ(converter.converted_conjuncts(), 0);
+    EXPECT_EQ(converter.converted_runtime_filters(), 0);
+}
+
+} // namespace doris
diff --git a/be/test/format_v2/table/paimon_rust_table_reader_test.cpp b/be/test/format_v2/table/paimon_rust_table_reader_test.cpp
new file mode 100644
index 00000000000..72094f3e020
--- /dev/null
+++ b/be/test/format_v2/table/paimon_rust_table_reader_test.cpp
@@ -0,0 +1,712 @@
+// 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.
+
+#include "format_v2/table/paimon_rust_table_reader.h"
+
+#include <gtest/gtest.h>
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <iterator>
+#include <map>
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "cctz/time_zone.h"
+#include "core/block/block.h"
+#include "core/column/column_const.h"
+#include "core/column/column_nullable.h"
+#include "core/data_type/data_type_nullable.h"
+#include "core/data_type/data_type_number.h"
+#include "core/data_type/data_type_string.h"
+#include "core/field.h"
+#include "exprs/vexpr_context.h"
+#include "exprs/vliteral.h"
+#include "format_v2/column_data.h"
+#include "gen_cpp/PlanNodes_types.h"
+#include "runtime/runtime_profile.h"
+#include "runtime/runtime_state.h"
+#include "util/url_coding.h"
+
+namespace doris::format::paimon {
+namespace {
+
+ColumnDefinition make_column(const std::string& name, const DataTypePtr& type,
+                             bool is_partition_key = false) {
+    ColumnDefinition column;
+    column.name = name;
+    column.type = type->is_nullable() ? type : make_nullable(type);
+    column.is_partition_key = is_partition_key;
+    return column;
+}
+
+TFileRangeDesc make_rust_range() {
+    TFileRangeDesc range;
+    TTableFormatFileDesc table_format_params;
+    table_format_params.__set_table_format_type("paimon");
+    TPaimonFileDesc paimon_params;
+    paimon_params.__set_reader_type(TPaimonReaderType::PAIMON_RUST);
+    std::string encoded;
+    base64_encode("dummy-split-bytes", &encoded);
+    paimon_params.__set_paimon_split(encoded);
+    paimon_params.__set_paimon_table("/paimon/warehouse/db.db/t");
+    paimon_params.__set_db_name("db");
+    paimon_params.__set_table_name("t");
+    paimon_params.__set_paimon_table_schema_json("{}");
+    table_format_params.__set_paimon_params(paimon_params);
+    range.__set_table_format_params(table_format_params);
+    return range;
+}
+
+// A range whose table path carries the given URI scheme — the production
+// shape FE ships in paimon_table (PaimonScanNode sets it from the catalog's
+// table location).
+TFileRangeDesc make_rust_range_with_table_path(const std::string& table_path) {
+    auto range = make_rust_range();
+    range.table_format_params.paimon_params.__set_paimon_table(table_path);
+    return range;
+}
+
+} // namespace
+
+class PaimonRustTableReaderTest : public testing::Test {
+protected:
+    void SetUp() override {
+        _query_options.__set_batch_size(3);
+        _runtime_state = RuntimeState::create_unique(_query_options, _query_globals);
+    }
+
+    Status init_reader_with_count(PaimonRustTableReader* reader,
+                                  std::vector<GlobalIndex> count_columns) {
+        return reader->init({.projected_columns = {_projected_column},
+                             .conjuncts = {},
+                             .format = FileFormat::JNI,
+                             .scan_params = nullptr,
+                             .io_ctx = nullptr,
+                             .runtime_state = _runtime_state.get(),
+                             .scanner_profile = nullptr,
+                             .push_down_agg_type = TPushAggOp::type::COUNT,
+                             .push_down_count_columns = std::move(count_columns)});
+    }
+
+    TQueryOptions _query_options;
+    TQueryGlobals _query_globals;
+    std::unique_ptr<RuntimeState> _runtime_state;
+    ColumnDefinition _projected_column = make_column("k", std::make_shared<DataTypeInt32>());
+};
+
+TEST_F(PaimonRustTableReaderTest, ValidatesRustSplit) {
+    PaimonRustTableReader reader;
+
+    // Missing paimon_split.
+    auto range = make_rust_range();
+    range.table_format_params.paimon_params.__isset.paimon_split = false;
+    auto status = reader.TEST_validate_rust_split(range);
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("missing paimon_split"), std::string::npos) << status;
+
+    // Missing paimon_table (table path).
+    range = make_rust_range();
+    range.table_format_params.paimon_params.__isset.paimon_table = false;
+    status = reader.TEST_validate_rust_split(range);
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("missing paimon_table"), std::string::npos) << status;
+
+    // Missing db_name.
+    range = make_rust_range();
+    range.table_format_params.paimon_params.__isset.db_name = false;
+    status = reader.TEST_validate_rust_split(range);
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("missing db_name"), std::string::npos) << status;
+
+    // Missing table_name.
+    range = make_rust_range();
+    range.table_format_params.paimon_params.__isset.table_name = false;
+    status = reader.TEST_validate_rust_split(range);
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("missing table_name"), std::string::npos) << status;
+
+    // Missing paimon_table_schema_json.
+    range = make_rust_range();
+    range.table_format_params.paimon_params.__isset.paimon_table_schema_json = false;
+    status = reader.TEST_validate_rust_split(range);
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("missing paimon_table_schema_json"), std::string::npos)
+            << status;
+
+    // A mismatched reader_type is a protocol error.
+    range = make_rust_range();
+    range.table_format_params.paimon_params.__set_reader_type(TPaimonReaderType::PAIMON_JNI);
+    status = reader.TEST_validate_rust_split(range);
+    EXPECT_FALSE(status.ok());
+    EXPECT_NE(status.to_string().find("invalid reader_type"), std::string::npos) << status;
+
+    // A complete range validates cleanly.
+    EXPECT_TRUE(reader.TEST_validate_rust_split(make_rust_range()).ok());
+}
+
+TEST_F(PaimonRustTableReaderTest, AdaptiveBatchSizeBoundsWideStringReads) {
+    const auto fixture = std::filesystem::path(__FILE__).parent_path() /
+                         "../../exec/test_data/paimon_rust_batch_size";
+    const auto read_file = [](const std::filesystem::path& path) {
+        std::ifstream input(path, std::ios::binary);
+        return std::string(std::istreambuf_iterator<char>(input), std::istreambuf_iterator<char>());
+    };
+    auto split = read_file(fixture / "split.bin");
+    const auto placeholder = split.find("__BUCKET__");
+    ASSERT_NE(placeholder, std::string::npos);
+    ASSERT_GE(placeholder, 2);
+    const auto bucket = std::filesystem::canonical(fixture / "bucket-0").string();
+    ASSERT_LT(bucket.size(), 65536);
+    // The Java DataSplit fixture uses a neutral bucket path, encoded with writeUTF.
+    const std::string length = {static_cast<char>(bucket.size() >> 8),
+                                static_cast<char>(bucket.size() & 0xff)};
+    split.replace(placeholder - 2, 2 + std::string("__BUCKET__").size(), length + bucket);
+    std::string encoded;
+    base64_encode(split, &encoded);
+    SplitReadOptions options;
+    options.current_range = make_rust_range();
+    options.current_split_format = FileFormat::JNI;
+    options.all_runtime_filters_applied = true;
+    auto& params = options.current_range.table_format_params.paimon_params;
+    params.__set_paimon_table(std::filesystem::canonical(fixture).string());
+    params.__set_paimon_table_schema_json(read_file(fixture / "schema/schema-0"));
+    params.__set_paimon_split(encoded);
+
+    const auto value_column = make_column("v", std::make_shared<DataTypeString>());
+    PaimonRustTableReader reader;
+    ASSERT_TRUE(reader.init({.projected_columns = {_projected_column, value_column},
+                             .conjuncts = {},
+                             .format = FileFormat::JNI,
+                             .scan_params = nullptr,
+                             .io_ctx = nullptr,
+                             .runtime_state = _runtime_state.get(),
+                             .scanner_profile = nullptr})
+                        .ok());
+    // Wide values must be bounded before Arrow decoding, including when reusing a table handle.
+    for (const size_t requested : {0, 32, 7}) {
+        if (requested != 0) {
+            reader.set_batch_size(requested);
+        }
+        const size_t expected = requested == 0 ? 3 : requested;
+        auto status = reader.prepare_split(options);
+        ASSERT_TRUE(status.ok()) << status;
+        Block block({ColumnWithTypeAndName(_projected_column.type->create_column(),
+                                           _projected_column.type, "k"),
+                     ColumnWithTypeAndName(value_column.type->create_column(), value_column.type,
+                                           "v")});
+        size_t total = 0;
+        bool eos = false;
+        while (!eos) {
+            status = reader.get_block(&block, &eos);
+            ASSERT_TRUE(status.ok()) << status;
+            ASSERT_LE(block.rows(), expected);
+            const auto& keys = assert_cast<const ColumnNullable&>(*block.get_by_position(0).column);
+            const auto& values =
+                    assert_cast<const ColumnNullable&>(*block.get_by_position(1).column);
+            for (size_t row = 0; row < block.rows(); ++row) {
+                ASSERT_FALSE(keys.is_null_at(row));
+                ASSERT_FALSE(values.is_null_at(row));
+                EXPECT_EQ(keys.get_nested_column().get_int(row), total + row);
+                EXPECT_EQ(values.get_nested_column().get_data_at(row).to_string(),
+                          std::string(128 * 1024, 'x'));
+            }
+            total += block.rows();
+        }
+        EXPECT_EQ(total, 65);
+        ASSERT_TRUE(reader.abort_split().ok());
+    }
+    ASSERT_TRUE(reader.close().ok());
+}
+
+TEST_F(PaimonRustTableReaderTest, TableLevelCountEmitsSyntheticRows) {
+    // COUNT(*) with a table-level row count takes the base-class metadata path:
+    // prepare_split never opens the rust pipeline and get_block emits synthetic rows.
+    PaimonRustTableReader reader;
+    ASSERT_TRUE(init_reader_with_count(&reader, std::vector<GlobalIndex> {}).ok());
+
+    SplitReadOptions options;
+    options.current_range = make_rust_range();
+    options.current_split_format = FileFormat::JNI;
+    options.all_runtime_filters_applied = true;
+    options.current_range.table_format_params.__set_table_level_row_count(5);
+    ASSERT_TRUE(reader.prepare_split(options).ok());
+    EXPECT_TRUE(reader.current_split_uses_metadata_count());
+
+    Block block = Block({ColumnWithTypeAndName(_projected_column.type->create_column(),
+                                               _projected_column.type, _projected_column.name)});
+    bool eos = false;
+    // The base-class count contract emits batches until a call finds remaining==0:
+    // batch_size(3) splits 5 rows into 3 + 2, and only the following call reports eos.
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    EXPECT_EQ(block.rows(), 3);
+    EXPECT_FALSE(eos);
+
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    EXPECT_EQ(block.rows(), 2);
+    EXPECT_FALSE(eos);
+
+    ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+    EXPECT_EQ(block.rows(), 0);
+    EXPECT_TRUE(eos);
+}
+
+TEST_F(PaimonRustTableReaderTest, TableLevelCountDisabledByConjuncts) {
+    // A row predicate makes the metadata shortcut unsafe: the split must not report a
+    // metadata count. It proceeds to the rust pipeline instead, which fails on the dummy
+    // split bytes of this test range rather than emitting synthetic count rows.
+    PaimonRustTableReader reader;
+    ASSERT_TRUE(init_reader_with_count(&reader, std::vector<GlobalIndex> {}).ok());
+
+    SplitReadOptions options;
+    options.current_range = make_rust_range();
+    options.current_split_format = FileFormat::JNI;
+    options.all_runtime_filters_applied = true;
+    options.current_range.table_format_params.__set_table_level_row_count(5);
+    options.conjuncts = VExprContextSPtrs {};
+    options.conjuncts->push_back(VExprContext::create_shared(VLiteral::create_shared(
+            std::make_shared<DataTypeInt32>(), Field::create_field<TYPE_INT>(1))));
+
+    const auto status = reader.prepare_split(options);
+    EXPECT_FALSE(status.ok());
+    EXPECT_FALSE(reader.current_split_uses_metadata_count());
+
+    Block block = Block({ColumnWithTypeAndName(_projected_column.type->create_column(),
+                                               _projected_column.type, _projected_column.name)});
+    bool eos = false;
+    const auto get_block_status = reader.get_block(&block, &eos);
+    EXPECT_FALSE(get_block_status.ok());
+    EXPECT_NE(get_block_status.to_string().find("paimon-rust reader is not initialized"),
+              std::string::npos)
+            << get_block_status;
+}
+
+TEST_F(PaimonRustTableReaderTest, FailedSplitOpenIsIncludedInRustTotalTime) {
+    RuntimeProfile profile("scanner");
+    PaimonRustTableReader reader;
+    ASSERT_TRUE(reader.init({.projected_columns = {_projected_column},
+                             .conjuncts = {},
+                             .format = FileFormat::JNI,
+                             .scan_params = nullptr,
+                             .io_ctx = nullptr,
+                             .runtime_state = _runtime_state.get(),
+                             .scanner_profile = &profile})
+                        .ok());
+    SplitReadOptions options;
+    options.current_range = make_rust_range();
+    options.current_split_format = FileFormat::JNI;
+    // The invalid split fails during open, before get_block can contribute any time.
+    ASSERT_FALSE(reader.prepare_split(options).ok());
+    auto* total = profile.get_counter("PaimonRustReader");
+    auto* open = profile.get_counter("OpenSplitTime");
+    ASSERT_NE(total, nullptr);
+    ASSERT_NE(open, nullptr);
+    EXPECT_GT(open->value(), 0);
+    EXPECT_GE(total->value(), open->value());
+}
+
+TEST_F(PaimonRustTableReaderTest, FillsPartitionConstantsForMissingArrowColumns) {
+    PaimonRustTableReader reader;
+    const auto data_type = make_nullable(std::make_shared<DataTypeInt32>());
+    const auto partition_type = make_nullable(std::make_shared<DataTypeString>());
+    // The output block matches the projected columns exactly (get_block contract):
+    // position 0 is the data column k (absent from the arrow batch -> default fill),
+    // position 1 is the partition key dt (materialized from split metadata).
+    reader.TEST_set_projected_columns(
+            {make_column("k", std::make_shared<DataTypeInt32>()),
+             make_column("dt", std::make_shared<DataTypeString>(), /*is_partition_key=*/true)});
+    std::map<std::string, Field> partition_values;
+    partition_values.emplace("dt", Field::create_field<TYPE_STRING>("2024-01-01"));
+    reader.TEST_set_partition_values(std::move(partition_values));
+
+    Block block =
+            Block({ColumnWithTypeAndName(data_type->create_column(), data_type, "k"),
+                   ColumnWithTypeAndName(partition_type->create_column(), partition_type, "dt")});
+    const size_t rows = 4;
+    ASSERT_TRUE(reader.TEST_fill_non_arrow_columns(&block, rows).ok());
+
+    // The data column is absent from both the arrow batch and split metadata: filled
+    // with defaults.
+    EXPECT_EQ(block.get_by_position(0).column->size(), rows);
+
+    // The partition position is a constant column with the split value broadcast
+    // to every row.
+    const auto& column_with_type = block.get_by_position(1);
+    EXPECT_EQ(column_with_type.column->size(), rows);
+    const auto* const_column = check_and_get_column<ColumnConst>(*column_with_type.column);
+    ASSERT_NE(const_column, nullptr);
+    EXPECT_EQ(const_column->size(), rows);
+    const auto value_field = const_column->get_field();
+    const auto& value = value_field.get<TYPE_STRING>();
+    EXPECT_EQ(std::string(value.data(), value.size()), "2024-01-01");
+}
+
+TEST_F(PaimonRustTableReaderTest, TruncatesNullablePartitionAndDefaultConstants) {
+    _query_options.__set_truncate_char_or_varchar_columns(true);
+    _runtime_state = RuntimeState::create_unique(_query_options, _query_globals);
+    for (const auto primitive : {PrimitiveType::TYPE_CHAR, PrimitiveType::TYPE_VARCHAR}) {
+        for (bool is_null : {false, true}) {
+            PaimonRustTableReader reader;
+            ASSERT_TRUE(init_reader_with_count(&reader, {}).ok());
+            const auto type = make_nullable(std::make_shared<DataTypeString>(3, primitive));
+            auto partition = make_column("partition_key", type, true);
+            auto default_column = make_column("default_value", type);
+            const auto value = is_null ? Field() : Field::create_field<TYPE_STRING>("abcdef");
+            default_column.default_expr =
+                    VExprContext::create_shared(VLiteral::create_shared(type, value));
+            reader.TEST_set_projected_columns({partition, default_column});
+            reader.TEST_set_partition_values({{"partition_key", value}});
+            Block block({ColumnWithTypeAndName(type->create_column(), type, "partition_key"),
+                         ColumnWithTypeAndName(type->create_column(), type, "default_value")});
+            ASSERT_TRUE(reader.TEST_fill_non_arrow_columns(&block, 3).ok());
+            ASSERT_TRUE(is_column_const(*block.get_by_position(0).column));
+            // Split constants wrap nullable data; truncation must preserve both the row count
+            // and null map when it materializes that outer wrapper.
+            ASSERT_TRUE(reader.TEST_truncate_char_or_varchar_columns(&block).ok());
+            ASSERT_EQ(block.rows(), 3);
+            for (size_t column = 0; column < 2; ++column) {
+                for (size_t row = 0; row < 3; ++row) {
+                    Field actual;
+                    block.get_by_position(column).column->get(row, actual);
+                    if (is_null) {
+                        EXPECT_TRUE(actual.is_null());
+                    } else {
+                        const auto& text = actual.get<TYPE_STRING>();
+                        EXPECT_EQ(std::string(text.data(), text.size()), "abc");
+                    }
+                }
+            }
+        }
+    }
+}
+
+TEST_F(PaimonRustTableReaderTest, DirectPathTruncatesBoundedStringColumns) {
+    // The pinned rust reader maps paimon CHAR(n)/VARCHAR(n) to lengthless Arrow
+    // Utf8, so the direct Arrow path must enforce truncate_char_or_varchar_
+    // columns itself: a value written under an old wider schema and read into
+    // a column narrowed to VARCHAR(3) truncates to "abc" — the semantics
+    // TableReader::finalize_chunk applies on the normal path, which this
+    // direct path bypasses. Unbounded STRING columns and non-string columns
+    // stay untouched, and with the option off nothing truncates.
+    const auto fill_block = [] {
+        const auto varchar3 =
+                make_nullable(std::make_shared<DataTypeString>(3, PrimitiveType::TYPE_VARCHAR));
+        const auto string_type = make_nullable(std::make_shared<DataTypeString>());
+        const auto int_type = std::make_shared<DataTypeInt32>();
+        Block block = Block({ColumnWithTypeAndName(varchar3->create_column(), varchar3, "v"),
+                             ColumnWithTypeAndName(string_type->create_column(), string_type, "s"),
+                             ColumnWithTypeAndName(int_type->create_column(), int_type, "k")});
+        for (auto [idx, field] : std::initializer_list<std::pair<size_t, Field>> {
+                     {0, Field::create_field<TYPE_STRING>("abcdefghij")},
+                     {1, Field::create_field<TYPE_STRING>("0123456789ABCDEF")},
+                     {2, Field::create_field<TYPE_INT>(1)},
+                     {0, Field::create_field<TYPE_STRING>("ab")},
+                     {1, Field::create_field<TYPE_STRING>("xyz")},
+                     {2, Field::create_field<TYPE_INT>(2)}}) {
+            auto column = IColumn::mutate(block.get_by_position(idx).column);
+            column->insert(field);
+            block.get_by_position(idx).column = std::move(column);
+        }
+        return block;
+    };
+    const auto value_at = [](const Block& block, size_t idx, size_t row) {
+        Field field;
+        block.get_by_position(idx).column->get(row, field);
+        const auto& value = field.get<TYPE_STRING>();
+        return std::string(value.data(), value.size());
+    };
+
+    // Option off (the SetUp default): historical values return unchanged.
+    {
+        PaimonRustTableReader reader;
+        ASSERT_TRUE(init_reader_with_count(&reader, std::vector<GlobalIndex> {}).ok());
+        Block block = fill_block();
+        ASSERT_TRUE(reader.TEST_truncate_char_or_varchar_columns(&block).ok());
+        EXPECT_EQ(value_at(block, 0, 0), "abcdefghij");
+    }
+
+    // Option on: the VARCHAR(3) column truncates to its declared length; the
+    // unbounded STRING column and the INT column stay untouched.
+    _query_options.__set_truncate_char_or_varchar_columns(true);
+    _runtime_state = RuntimeState::create_unique(_query_options, _query_globals);
+    {
+        PaimonRustTableReader reader;
+        ASSERT_TRUE(init_reader_with_count(&reader, std::vector<GlobalIndex> {}).ok());
+        Block block = fill_block();
+        ASSERT_TRUE(reader.TEST_truncate_char_or_varchar_columns(&block).ok());
+        EXPECT_EQ(value_at(block, 0, 0), "abc");
+        EXPECT_EQ(value_at(block, 0, 1), "ab");
+        EXPECT_EQ(value_at(block, 1, 0), "0123456789ABCDEF");
+        EXPECT_EQ(value_at(block, 1, 1), "xyz");
+        Field int_field;
+        block.get_by_position(2).column->get(0, int_field);
+        EXPECT_EQ(int_field.get<TYPE_INT>(), 1);
+    }
+}
+
+TEST_F(PaimonRustTableReaderTest, MaterializesInSessionTimezone) {
+    // TIMESTAMP_LTZ values materialize as session-local civil times: the
+    // materialization timezone must come from the session, not a fixed default
+    // (epoch 0 reads as 08:00 in a +08:00 session and 00:00 in UTC).
+    const auto hour_of_epoch_zero = [](const cctz::time_zone& tz) {
+        return tz.lookup(cctz::time_point<cctz::seconds>(std::chrono::seconds(0))).cs.hour();
+    };
+
+    PaimonRustTableReader reader;
+    _runtime_state->set_timezone("+08:00");
+    ASSERT_TRUE(init_reader_with_count(&reader, std::vector<GlobalIndex> {}).ok());
+    EXPECT_EQ(hour_of_epoch_zero(reader.TEST_ctz()), 8);
+
+    PaimonRustTableReader utc_reader;
+    _runtime_state->set_timezone("UTC");
+    ASSERT_TRUE(init_reader_with_count(&utc_reader, std::vector<GlobalIndex> {}).ok());
+    EXPECT_EQ(hour_of_epoch_zero(utc_reader.TEST_ctz()), 0);
+}
+
+TEST_F(PaimonRustTableReaderTest, OptionLogRendersKeysOnly) {
+    // Storage-option values must never reach the log: credential keys arrive
+    // under many spellings and cases (AWS_SECRET_KEY, AWS_TOKEN,
+    // fs.oss.accessKeySecret, s3.secret-key, ...), and a key-name blocklist
+    // that misses one alias leaks the value, so the diagnostics rendering
+    // prints key names only.
+    const std::map<std::string, std::string> options {
+            {"AWS_ACCESS_KEY", "admin"},
+            {"AWS_SECRET_KEY", "leak-if-logged-1"},
+            {"AWS_TOKEN", "leak-if-logged-2"},
+            {"fs.oss.accessKeySecret", "leak-if-logged-3"},
+            {"s3.access-key", "leak-if-logged-4"},
+            {"s3.secret-key", "leak-if-logged-5"},
+            {"s3.endpoint", "http://leak-if-logged-6:19001"},
+    };
+    const std::string rendered = PaimonRustTableReader::TEST_format_options(options);
+
+    // Every key is rendered, no value and no '=' separator ever is.
+    EXPECT_NE(rendered.find("AWS_SECRET_KEY"), std::string::npos);
+    EXPECT_NE(rendered.find("fs.oss.accessKeySecret"), std::string::npos);
+    EXPECT_NE(rendered.find("s3.secret-key"), std::string::npos);
+    EXPECT_EQ(rendered.find('='), std::string::npos);
+    for (int i = 1; i <= 6; ++i) {
+        EXPECT_EQ(rendered.find("leak-if-logged-" + std::to_string(i)), std::string::npos)
+                << rendered;
+    }
+    EXPECT_EQ(rendered.find("admin"), std::string::npos) << rendered;
+}
+
+TEST_F(PaimonRustTableReaderTest, MapsAnonymousAndAssumeRoleProviderModes) {
+    // The FE storage-properties channel marks anonymous access with
+    // AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS and assume-role with
+    // AWS_ROLE_ARN / AWS_EXTERNAL_ID; the pinned rust S3 parser reads
+    // s3.anonymous (skip_signature) and the s3.assumed.role.* family, so the
+    // bridge must map both — without the mapping, anonymous catalogs would
+    // consult the ambient credential chain and role-only catalogs would
+    // never assume the requested role. The ambient JVM provider modes (ENV,
+    // SYSTEM_PROPERTIES, WEB_IDENTITY, CONTAINER, INSTANCE_PROFILE) have no
+    // rust equivalent and are gated away from the rust reader on the FE.
+    TFileScanRangeParams params;
+    params.properties["AWS_CREDENTIALS_PROVIDER_TYPE"] = "ANONYMOUS";
+    params.properties["AWS_ROLE_ARN"] = "arn:aws:iam::123:role/reader";
+    params.properties["AWS_EXTERNAL_ID"] = "external-123";
+    params.properties["AWS_ACCESS_KEY"] = "admin";
+    params.properties["AWS_SECRET_KEY"] = "password";
+    params.properties["AWS_ENDPOINT"] = "http://127.0.0.1:19001";
+    params.properties["AWS_REGION"] = "us-east-1";
+    params.properties["use_path_style"] = "true";
+    params.__isset.properties = true;
+
+    PaimonRustTableReader reader;
+    const auto options = reader.TEST_build_options(
+            &params, make_rust_range_with_table_path("s3://bucket/wh/db.db/t"));
+    EXPECT_EQ(options.at("s3.anonymous"), "true");
+    EXPECT_EQ(options.at("s3.assumed.role.arn"), "arn:aws:iam::123:role/reader");
+    EXPECT_EQ(options.at("s3.assumed.role.externalId"), "external-123");
+    // Static credentials and connection settings keep mapping (regression
+    // for the base remap).
+    EXPECT_EQ(options.at("s3.access-key"), "admin");
+    EXPECT_EQ(options.at("s3.secret-key"), "password");
+    EXPECT_EQ(options.at("s3.endpoint"), "http://127.0.0.1:19001");
+    EXPECT_EQ(options.at("s3.region"), "us-east-1");
+    EXPECT_EQ(options.at("s3.path-style-access"), "true");
+}
+
+TEST_F(PaimonRustTableReaderTest, StaticCredentialsMapWithoutProviderMode) {
+    // Default shape: no provider-mode marker, plain static credentials.
+    TFileScanRangeParams params;
+    params.properties["AWS_ACCESS_KEY"] = "admin";
+    params.properties["AWS_SECRET_KEY"] = "password";
+    params.__isset.properties = true;
+
+    PaimonRustTableReader reader;
+    const auto options = reader.TEST_build_options(
+            &params, make_rust_range_with_table_path("s3://bucket/wh/db.db/t"));
+    EXPECT_EQ(options.at("s3.access-key"), "admin");
+    EXPECT_EQ(options.at("s3.secret-key"), "password");
+    EXPECT_EQ(options.count("s3.anonymous"), 0);
+}
+
+TEST_F(PaimonRustTableReaderTest, OssSchemeMapsToOssFileIOKeys) {
+    // The pinned storage dispatcher selects the parser from the table path's
+    // URI scheme: oss:// tables read the OSS parser, which requires
+    // fs.oss.endpoint / fs.oss.accessKeyId / fs.oss.accessKeySecret (plus
+    // optional fs.oss.securityToken for STS). A production-shaped FE map
+    // (OSSProperties emits the AWS_* aliases plus the connection settings)
+    // must therefore map to the fs.oss.* family — mapping everything to
+    // s3.* leaves OSS catalogs failing to open ("Missing required OSS
+    // config: fs.oss.endpoint").
+    TFileRangeDesc range;
+    TTableFormatFileDesc table_format_params;
+    TPaimonFileDesc paimon_params;
+    paimon_params.__set_paimon_table("oss://bucket/wh/db.db/t");
+    table_format_params.__set_paimon_params(paimon_params);
+    range.__set_table_format_params(table_format_params);
+
+    TFileScanRangeParams params;
+    params.properties["AWS_ENDPOINT"] = "http://oss.internal:8080";
+    params.properties["AWS_ACCESS_KEY"] = "oss-admin";
+    params.properties["AWS_SECRET_KEY"] = "oss-password";
+    params.properties["AWS_TOKEN"] = "oss-sts-token";
+    params.properties["AWS_REGION"] = "cn-beijing";
+    params.properties["use_path_style"] = "true";
+    params.__isset.properties = true;
+
+    PaimonRustTableReader reader;
+    const auto options = reader.TEST_build_options(&params, range);
+    EXPECT_EQ(options.at("fs.oss.endpoint"), "http://oss.internal:8080");
+    EXPECT_EQ(options.at("fs.oss.accessKeyId"), "oss-admin");
+    EXPECT_EQ(options.at("fs.oss.accessKeySecret"), "oss-password");
+    EXPECT_EQ(options.at("fs.oss.securityToken"), "oss-sts-token");
+    // The OSS parser reads no s3.* keys; nothing but the fs.oss.* family may
+    // be synthesized for this scheme.
+    EXPECT_EQ(options.count("s3.access-key"), 0);
+    EXPECT_EQ(options.count("s3.endpoint"), 0);
+    EXPECT_EQ(options.count("s3.path-style-access"), 0);
+}
+
+TEST_F(PaimonRustTableReaderTest, OssSchemeKeepsNativeFsOssKeysUnmapped) {
+    // Native fs.oss.* options (delivered by configs that already speak the
+    // paimon-java OSS dialect) must pass through untouched instead of being
+    // remapped into the s3.* family.
+    TFileRangeDesc range;
+    TTableFormatFileDesc table_format_params;
+    TPaimonFileDesc paimon_params;
+    paimon_params.__set_paimon_table("oss://bucket/wh/db.db/t");
+    table_format_params.__set_paimon_params(paimon_params);
+    range.__set_table_format_params(table_format_params);
+
+    TFileScanRangeParams params;
+    params.properties["fs.oss.endpoint"] = "http://oss.internal:8080";
+    params.properties["fs.oss.accessKeyId"] = "oss-admin";
+    params.properties["fs.oss.accessKeySecret"] = "oss-password";
+    params.__isset.properties = true;
+
+    PaimonRustTableReader reader;
+    const auto options = reader.TEST_build_options(&params, range);
+    EXPECT_EQ(options.at("fs.oss.endpoint"), "http://oss.internal:8080");
+    EXPECT_EQ(options.at("fs.oss.accessKeyId"), "oss-admin");
+    EXPECT_EQ(options.at("fs.oss.accessKeySecret"), "oss-password");
+    EXPECT_EQ(options.count("s3.access-key"), 0);
+}
+
+TEST_F(PaimonRustTableReaderTest, S3aSchemeMapsToS3FileIOKeys) {
+    // The crate dispatches both s3:// and s3a:// to the S3 parser, so the
+    // legacy Hadoop-style spelling must translate to the s3.* family too.
+    TFileScanRangeParams params;
+    params.properties["AWS_ACCESS_KEY"] = "admin";
+    params.properties["AWS_SECRET_KEY"] = "password";
+    params.properties["AWS_ENDPOINT"] = "http://127.0.0.1:19001";
+    params.__isset.properties = true;
+
+    PaimonRustTableReader reader;
+    const auto options = reader.TEST_build_options(
+            &params, make_rust_range_with_table_path("s3a://bucket/wh/db.db/t"));
+    EXPECT_EQ(options.at("s3.access-key"), "admin");
+    EXPECT_EQ(options.at("s3.secret-key"), "password");
+    EXPECT_EQ(options.at("s3.endpoint"), "http://127.0.0.1:19001");
+}
+
+TEST_F(PaimonRustTableReaderTest, UnverifiedSchemesPassThroughWithoutS3Aliases) {
+    // libpaimon_c.a compiles in separate COS, OBS, GCS and Azdls parsers,
+    // each reading its own key family (fs.cosn.userinfo.* / fs.obs.* /
+    // gcs.* / azure.*), while Doris's FE normalizes those object stores'
+    // credentials into the AWS_* aliases (the *Properties storage classes).
+    // The bridge only implements the s3.* / fs.oss.* translations, and the
+    // FE gates every other scheme to JNI before the split is encoded — so
+    // here nothing may be synthesized for them: a production-shaped AWS_*
+    // map must pass through untouched instead of being silently translated
+    // into s3.* keys its scheme's parser ignores (which would fail the open
+    // with a misleading auth error, e.g. "Missing required COS config").
+    const std::vector<std::string> gated_schemes {
+            "cosn://bucket/wh/db.db/t",  "cos://bucket/wh/db.db/t", "obs://bucket/wh/db.db/t",
+            "gs://bucket/wh/db.db/t",    "gcs://bucket/wh/db.db/t", "abfs://bucket/wh/db.db/t",
+            "abfss://bucket/wh/db.db/t", "az://bucket/wh/db.db/t",  "azure://bucket/wh/db.db/t"};
+    for (const auto& table_path : gated_schemes) {
+        TFileScanRangeParams params;
+        params.properties["AWS_CREDENTIALS_PROVIDER_TYPE"] = "DEFAULT";
+        params.properties["AWS_ACCESS_KEY"] = "admin";
+        params.properties["AWS_SECRET_KEY"] = "password";
+        params.properties["AWS_ENDPOINT"] = "http://127.0.0.1:19001";
+        params.properties["AWS_REGION"] = "us-east-1";
+        params.properties["AWS_TOKEN"] = "sts-token";
+        params.properties["use_path_style"] = "true";
+        params.__isset.properties = true;
+
+        PaimonRustTableReader reader;
+        const auto options =
+                reader.TEST_build_options(&params, make_rust_range_with_table_path(table_path));
+        // No key of the s3.* / fs.oss.* families is synthesized for a scheme
+        // whose parser reads neither.
+        for (const char* key :
+             {"s3.access-key", "s3.secret-key", "s3.session.token", "s3.endpoint", "s3.region",
+              "s3.path-style-access", "s3.anonymous", "s3.assumed.role.arn",
+              "s3.assumed.role.externalId", "fs.oss.endpoint", "fs.oss.accessKeyId",
+              "fs.oss.accessKeySecret"}) {
+            EXPECT_EQ(options.count(key), 0) << "scheme " << table_path << " synthesized " << key;
+        }
+        // The FE-supplied property map passes through untouched.
+        EXPECT_EQ(options.at("AWS_ACCESS_KEY"), "admin");
+        EXPECT_EQ(options.at("AWS_SECRET_KEY"), "password");
+        EXPECT_EQ(options.at("AWS_ENDPOINT"), "http://127.0.0.1:19001");
+    }
+}
+
+TEST_F(PaimonRustTableReaderTest, HdfsAndLocalSchemesPassThroughHadoopConf) {
+    // hdfs:// tables read their hadoop conf as delivered (the native HDFS
+    // parser has its own config keys) and credential-free local paths need
+    // nothing at all: no AWS_* -> s3.* aliasing may be synthesized for
+    // either, even if an S3-shaped property map is present.
+    const std::vector<std::string> passthrough_schemes {"hdfs://nn/wh/db.db/t",
+                                                        "/paimon/warehouse/db.db/t"};
+    for (const auto& table_path : passthrough_schemes) {
+        TFileScanRangeParams params;
+        params.properties["AWS_ACCESS_KEY"] = "admin";
+        params.properties["AWS_SECRET_KEY"] = "password";
+        params.properties["AWS_ENDPOINT"] = "http://127.0.0.1:19001";
+        params.__isset.properties = true;
+
+        PaimonRustTableReader reader;
+        const auto options =
+                reader.TEST_build_options(&params, make_rust_range_with_table_path(table_path));
+        EXPECT_EQ(options.count("s3.access-key"), 0) << table_path;
+        EXPECT_EQ(options.count("s3.secret-key"), 0) << table_path;
+        EXPECT_EQ(options.count("s3.endpoint"), 0) << table_path;
+        EXPECT_EQ(options.count("fs.oss.accessKeyId"), 0) << table_path;
+        EXPECT_EQ(options.at("AWS_ACCESS_KEY"), "admin");
+    }
+}
+
+} // namespace doris::format::paimon
diff --git a/build.sh b/build.sh
index 270c64f11d0..8449eab271a 100755
--- a/build.sh
+++ b/build.sh
@@ -447,7 +447,22 @@ else
     LAST_THIRDPARTY_LIB='hadoop_hdfs/native/libhdfs.a'
 fi
 
-if [[ ! -f "${DORIS_THIRDPARTY}/installed/lib/${LAST_THIRDPARTY_LIB}" ]]; then
+# Old installs can contain the sentinel but predate Paimon. Rebuild the whole
+# dependency set so Lance and Paimon also use the same selected Rust toolchain.
+# An interrupted header/archive publication must be repaired before either file is reused.
+if [[ ! -f "${DORIS_THIRDPARTY}/installed/lib/${LAST_THIRDPARTY_LIB}" ||
+      ! -f "${DORIS_THIRDPARTY}/installed/lib64/liblance_c.a" ||
+      ! -f "${DORIS_THIRDPARTY}/installed/lib64/libpaimon_c.a" ||
+      ! -f "${DORIS_THIRDPARTY}/installed/include/paimon_rust/paimon.h" ||
+      ! -s "${DORIS_THIRDPARTY}/installed/lib64/libpaimon_c.a" ||
+      ! -s "${DORIS_THIRDPARTY}/installed/include/paimon_rust/paimon.h" ||
+      -e "${DORIS_THIRDPARTY}/installed/lib64/.paimon-installing" ]]; then
+    # Compilation images may contain only installed artifacts; never erase them without a rebuild source.
+    if [[ ! -f "${DORIS_THIRDPARTY}/build-thirdparty.sh" ]]; then
+        echo "Third-party dependencies require a rebuild, but build-thirdparty.sh is missing." >&2
+        echo "Refresh the compilation image or set DORIS_THIRDPARTY to a complete third-party source tree." >&2
+        exit 1
+    fi
     echo "Thirdparty libraries need to be build ..."
     # need remove all installed pkgs because some lib like lz4 will throw error if its lib alreay exists
     rm -rf "${DORIS_THIRDPARTY}/installed"
diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/profile/ExecutionProfile.java b/fe/fe-core/src/main/java/org/apache/doris/common/profile/ExecutionProfile.java
index 2f6b0992ef6..185fce52d35 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/common/profile/ExecutionProfile.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/common/profile/ExecutionProfile.java
@@ -35,9 +35,11 @@ import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Set;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 
 /**
@@ -71,6 +73,7 @@ public class ExecutionProfile {
 
     // use to merge profile from multi be
     private Map<Integer, Map<TNetworkAddress, List<RuntimeProfile>>> multiBeProfile = null;
+    private Map<Integer, Set<TNetworkAddress>> fragmentIdDoneBackends;
     private ReentrantReadWriteLock multiBeProfileLock = new ReentrantReadWriteLock();
 
     // Not serialize this property, it is only used to get profile id.
@@ -88,6 +91,7 @@ public class ExecutionProfile {
         root.addChild(fragmentsProfile, true);
         fragmentProfiles = Maps.newHashMap();
         multiBeProfile = Maps.newHashMap();
+        fragmentIdDoneBackends = Maps.newHashMap();
         fragmentIdBeNum = Maps.newHashMap();
         seqNoToFragmentId = Maps.newHashMap();
         int i = 0;
@@ -96,6 +100,7 @@ public class ExecutionProfile {
             fragmentProfiles.put(fragmentId, runtimeProfile);
             fragmentsProfile.addChild(runtimeProfile, true);
             multiBeProfile.put(fragmentId, Maps.newHashMap());
+            fragmentIdDoneBackends.put(fragmentId, new HashSet<>());
             fragmentIdBeNum.put(fragmentId, 0);
             seqNoToFragmentId.put(i, fragmentId);
             ++i;
@@ -147,7 +152,7 @@ public class ExecutionProfile {
     }
 
     protected void setMultiBeProfile(int fragmentId, TNetworkAddress backendHBAddress,
-                                List<RuntimeProfile> taskProfile) {
+            List<RuntimeProfile> taskProfile) {
         multiBeProfileLock.writeLock().lock();
         try {
             multiBeProfile.get(fragmentId).put(backendHBAddress, taskProfile);
@@ -156,6 +161,40 @@ public class ExecutionProfile {
         }
     }
 
+    private boolean updateMultiBeProfile(int fragmentId, TNetworkAddress backendHBAddress,
+            List<RuntimeProfile> taskProfile, List<RuntimeProfile> profileNodes, boolean isDone) {
+        multiBeProfileLock.writeLock().lock();
+        try {
+            if (!isDone && fragmentIdDoneBackends.get(fragmentId).contains(backendHBAddress)) {
+                return false;
+            }
+            multiBeProfile.get(fragmentId).put(backendHBAddress, taskProfile);
+            // Publish both rendered forms before completion becomes visible to profile readers.
+            for (RuntimeProfile profileNode : profileNodes) {
+                fragmentProfiles.get(fragmentId).addChild(profileNode, true);
+            }
+            if (isDone) {
+                fragmentIdDoneBackends.get(fragmentId).add(backendHBAddress);
+            }
+            return true;
+        } finally {
+            multiBeProfileLock.writeLock().unlock();
+        }
+    }
+
+    private boolean areAllBackendProfilesDone(int fragmentId, int expectedBackendNum) {
+        if (expectedBackendNum == 0) {
+            return false;
+        }
+
+        multiBeProfileLock.readLock().lock();
+        try {
+            return fragmentIdDoneBackends.get(fragmentId).size() >= expectedBackendNum;
+        } finally {
+            multiBeProfileLock.readLock().unlock();
+        }
+    }
+
     protected RuntimeProfile getPipelineAggregatedProfile(Map<Integer, String> planNodeMap) {
         RuntimeProfile fragmentsProfile = new RuntimeProfile("Fragments");
         for (int i = 0; i < fragmentProfiles.size(); ++i) {
@@ -233,6 +272,7 @@ public class ExecutionProfile {
             int fragmentId = entry.getKey();
             List<TDetailedReportParams> fragmentProfile = entry.getValue();
             int pipelineIdx = 0;
+            List<RuntimeProfile> profileNodes = Lists.newArrayList();
             List<RuntimeProfile> taskProfile = Lists.newArrayList();
             String suffix = "(host=" + backendHBAddress + ")";
             for (TDetailedReportParams pipelineProfile : fragmentProfile) {
@@ -259,9 +299,11 @@ public class ExecutionProfile {
 
                 profileNode.update(pipelineProfile.profile);
                 profileNode.setIsDone(isDone);
-                fragmentProfiles.get(fragmentId).addChild(profileNode, true);
+                profileNodes.add(profileNode);
+            }
+            if (!updateMultiBeProfile(fragmentId, backendHBAddress, taskProfile, profileNodes, isDone)) {
+                continue;
             }
-            setMultiBeProfile(fragmentId, backendHBAddress, taskProfile);
         }
 
         LOG.info("Profile update finished query: {} fragments: {} isDone: {}",
@@ -277,29 +319,24 @@ public class ExecutionProfile {
     }
 
     public synchronized void addFragmentBackend(PlanFragmentId fragmentId, Long backendId) {
-        fragmentIdBeNum.put(fragmentId.asInt(), fragmentIdBeNum.get(fragmentId.asInt()) + 1);
+        addFragmentBackend(fragmentId.asInt(), backendId);
+    }
+
+    public synchronized void addFragmentBackend(int fragmentId, Long backendId) {
+        fragmentIdBeNum.put(fragmentId, fragmentIdBeNum.get(fragmentId) + 1);
     }
 
     public TUniqueId getQueryId() {
         return queryId;
     }
 
-    // Check all fragments's child, if all finished, then this execution profile is finished
+    // Check all fragments' backend reports. A backend can contribute multiple profile nodes.
     public boolean isCompleted() {
         for (Entry<Integer, RuntimeProfile> element : fragmentProfiles.entrySet()) {
-            RuntimeProfile fragmentProfile = element.getValue();
-            // If any fragment is empty, it means BE does not report the profile, then the total
-            // execution profile is not completed.
-            if (fragmentProfile.isEmpty()
-                    || fragmentProfile.getChildList().size() < fragmentIdBeNum.get(element.getKey())) {
+            int fragmentId = element.getKey();
+            if (!areAllBackendProfilesDone(fragmentId, fragmentIdBeNum.get(fragmentId))) {
                 return false;
             }
-            for (Pair<RuntimeProfile, Boolean> runtimeProfile : fragmentProfile.getChildList()) {
-                // If any child instance profile is not ready, then return false.
-                if (!(runtimeProfile.first.getIsDone() || runtimeProfile.first.getIsCancel())) {
-                    return false;
-                }
-            }
         }
         return true;
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/profile/Profile.java b/fe/fe-core/src/main/java/org/apache/doris/common/profile/Profile.java
index f8e5b7884a3..a7e84f78487 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/common/profile/Profile.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/common/profile/Profile.java
@@ -117,6 +117,8 @@ public class Profile {
     // 1. profile is stored to storage
     // 2. or profile is loaded from storage
     private String profileStoragePath = "";
+    private volatile String storageProfileCompletionState;
+    private volatile boolean executionProfilesReleased;
     // isQueryFinished means the coordinator or stmt executor is finished.
     // does not mean the profile report has finished, since the report is async.
     // finish of collection of profile is marked by isCompleted of ExecutionProfiles.
@@ -346,6 +348,7 @@ public class Profile {
             LOG.info("DebugPoint:Profile.profileSizeLimit, MAX_PROFILE_SIZE = {}", maxProfileSize);
         }
         // add summary to builder
+        updateProfileCompletionStateForDisplay();
         summaryProfile.prettyPrint(builder);
         if (!builder.isTruncated()) {
             getChangedSessionVars(builder);
@@ -557,6 +560,9 @@ public class Profile {
     }
 
     public void releaseMemory() {
+        // The manager also releases reports after a failed spill. An empty list then means
+        // lost reports, not successful collection; retain that distinction across retries.
+        executionProfilesReleased = true;
         this.executionProfiles.clear();
         this.changedSessionVarCache = "";
         this.physicalPlan = null;
@@ -619,6 +625,66 @@ public class Profile {
         return !Strings.isNullOrEmpty(profileStoragePath);
     }
 
+    // Query completion precedes asynchronous BE reports; persisted profiles must retain
+    // their terminal state even after the in-memory reports have been released.
+    public String getProfileCompletionState() {
+        String storageState = storageProfileCompletionState;
+        if (storageState != null) {
+            return storageState;
+        }
+        if (profileHasBeenStored()) {
+            String storedState = summaryProfile.getSummary().getInfoString(SummaryProfile.PROFILE_COMPLETION_STATE);
+            if (!Strings.isNullOrEmpty(storedState)) {
+                return storedState;
+            }
+            return SummaryProfile.PROFILE_COMPLETION_STATE_UNKNOWN;
+        }
+
+        if (executionProfilesReleased) {
+            return SummaryProfile.PROFILE_COMPLETION_STATE_INCOMPLETE;
+        }
+
+        if (!isQueryFinished) {
+            return SummaryProfile.PROFILE_COMPLETION_STATE_RUNNING;
+        }
+
+        for (int i = 0; i < executionProfiles.size(); i++) {
+            ExecutionProfile executionProfile = executionProfiles.get(i);
+            if (!executionProfile.isCompleted()) {
+                return SummaryProfile.PROFILE_COMPLETION_STATE_COLLECTING;
+            }
+        }
+        return SummaryProfile.PROFILE_COMPLETION_STATE_COMPLETE;
+    }
+
+    private String getProfileCompletionStateForStorage() {
+        if (executionProfilesReleased) {
+            return SummaryProfile.PROFILE_COMPLETION_STATE_INCOMPLETE;
+        }
+        if (!isQueryFinished) {
+            return SummaryProfile.PROFILE_COMPLETION_STATE_RUNNING;
+        }
+
+        for (int i = 0; i < executionProfiles.size(); i++) {
+            ExecutionProfile executionProfile = executionProfiles.get(i);
+            if (!executionProfile.isCompleted()) {
+                return SummaryProfile.PROFILE_COMPLETION_STATE_INCOMPLETE;
+            }
+        }
+        return SummaryProfile.PROFILE_COMPLETION_STATE_COMPLETE;
+    }
+
+    private synchronized void updateProfileCompletionStateForStorage() {
+        // Freeze the terminal state before serialization. Concurrent rendering must not
+        // replace INCOMPLETE with COLLECTING while the storage path is still unpublished.
+        storageProfileCompletionState = getProfileCompletionStateForStorage();
+        summaryProfile.setProfileCompletionState(storageProfileCompletionState);
+    }
+
+    private synchronized void updateProfileCompletionStateForDisplay() {
+        summaryProfile.setProfileCompletionState(getProfileCompletionState());
+    }
+
     // Profile IO threads races with Coordinator threads.
     public void markQueryFinished() {
         try {
@@ -668,6 +734,7 @@ public class Profile {
             DataOutputStream memoryDataStream = new DataOutputStream(memoryStream);
 
             // Write summary profile and execution profile content to memory
+            updateProfileCompletionStateForStorage();
             this.summaryProfile.write(memoryDataStream);
 
             SafeStringBuilder builder = new SafeStringBuilder();
@@ -688,6 +755,7 @@ public class Profile {
             this.profileStoragePath = profileFilePath;
 
         } catch (Exception e) {
+            storageProfileCompletionState = null;
             LOG.error("write {} summary profile failed", getId(), e);
             return;
         } finally {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java b/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java
index 15e5503bced..fe703ddde0f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/common/profile/SummaryProfile.java
@@ -62,6 +62,12 @@ public class SummaryProfile {
     public static final String END_TIME = "End Time";
     public static final String TOTAL_TIME = "Total";
     public static final String TASK_STATE = "Task State";
+    public static final String PROFILE_COMPLETION_STATE = "Profile Completion State";
+    public static final String PROFILE_COMPLETION_STATE_RUNNING = "RUNNING";
+    public static final String PROFILE_COMPLETION_STATE_COLLECTING = "COLLECTING";
+    public static final String PROFILE_COMPLETION_STATE_COMPLETE = "COMPLETE";
+    public static final String PROFILE_COMPLETION_STATE_INCOMPLETE = "INCOMPLETE";
+    public static final String PROFILE_COMPLETION_STATE_UNKNOWN = "UNKNOWN";
     public static final String USER = "User";
     public static final String DEFAULT_CATALOG = "Default Catalog";
     public static final String DEFAULT_DB = "Default Db";
@@ -470,6 +476,10 @@ public class SummaryProfile {
         return summaryProfile;
     }
 
+    public void setProfileCompletionState(String profileCompletionState) {
+        summaryProfile.addInfoString(PROFILE_COMPLETION_STATE, profileCompletionState);
+    }
+
     public RuntimeProfile getExecutionSummary() {
         return executionSummaryProfile;
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitor.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitor.java
index 9eca19a1584..464a1c02ea0 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitor.java
@@ -28,6 +28,7 @@ import org.apache.doris.datasource.DorisTypeVisitor;
 
 import org.apache.paimon.types.BigIntType;
 import org.apache.paimon.types.BooleanType;
+import org.apache.paimon.types.CharType;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DateType;
@@ -98,17 +99,21 @@ public class DorisToPaimonTypeVisitor extends DorisTypeVisitor<DataType> {
             return new FloatType();
         } else if (primitiveType.equals(PrimitiveType.DOUBLE)) {
             return new DoubleType();
-        } else if (primitiveType.isCharFamily()) {
+        } else if (primitiveType.equals(PrimitiveType.CHAR)) {
+            // DDL must retain bounds: widening to STRING changes historical narrowing casts.
+            return new CharType(((ScalarType) atomic).getLength());
+        } else if (primitiveType.equals(PrimitiveType.VARCHAR)) {
+            return new VarCharType(((ScalarType) atomic).getLength());
+        } else if (primitiveType.equals(PrimitiveType.STRING)) {
             return new VarCharType(VarCharType.MAX_LENGTH);
         } else if (primitiveType.equals(PrimitiveType.DATE) || primitiveType.equals(PrimitiveType.DATEV2)) {
             return new DateType();
         } else if (primitiveType.equals(PrimitiveType.DECIMALV2) || primitiveType.isDecimalV3Type()) {
             return new DecimalType(((ScalarType) atomic).getScalarPrecision(), ((ScalarType) atomic).getScalarScale());
-        } else if (primitiveType.equals(PrimitiveType.DATETIME)) {
-            return new TimestampType();
-        } else if (primitiveType.equals(PrimitiveType.DATETIMEV2)) {
-            // Preserve wall-clock semantics and fractional precision in the Paimon schema.
-            return new TimestampType(((ScalarType) atomic).getScalarScale());
+        } else if (primitiveType.equals(PrimitiveType.DATETIME) || primitiveType.equals(PrimitiveType.DATETIMEV2)) {
+            // Paimon interprets numeric timestamp strings in units derived from this precision.
+            return new TimestampType(primitiveType.equals(PrimitiveType.DATETIMEV2)
+                    ? ((ScalarType) atomic).getScalarScale() : 0);
         } else if (primitiveType.equals(PrimitiveType.TIMESTAMPTZ)) {
             // TIMESTAMPTZ represents an instant, so map it to Paimon's local-zoned timestamp.
             return new LocalZonedTimestampType(((ScalarType) atomic).getScalarScale());
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonScanParams.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonScanParams.java
index 7a1b9718c00..2905952aa79 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonScanParams.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonScanParams.java
@@ -24,6 +24,7 @@ import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.options.ConfigOption;
 import org.apache.paimon.options.FallbackKey;
+import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.source.snapshot.FullCompactedStartingScanner;
@@ -74,6 +75,28 @@ public final class PaimonScanParams {
 
     private static final Set<String> INHERITED_READ_STATE_KEYS = inheritedReadStateKeys();
 
+    // The selectors the pinned paimon-rust reader re-resolves in copy_with_time_travel; the
+    // inverse of isolateSnapshotRead for schema transport (see withoutTimeTravelSelectors).
+    private static final Set<String> TIME_TRAVEL_SELECTOR_KEYS = ImmutableSet.of(
+            CoreOptions.SCAN_TIMESTAMP_MILLIS.key(),
+            CoreOptions.SCAN_WATERMARK.key(),
+            CoreOptions.SCAN_VERSION.key(),
+            CoreOptions.SCAN_SNAPSHOT_ID.key(),
+            CoreOptions.SCAN_TAG_NAME.key());
+
+    // Paimon's copyInternal materializes the derived scan mode into the copied schema's
+    // options when the merged options carry a selector — a statement fence pinning
+    // scan.snapshot-id leaves a bare "scan.mode=from-snapshot" in schema.options(). Each
+    // of these modes requires a selector (the rust ReadBuilder rejects them otherwise),
+    // so withoutTimeTravelSelectors drops them together with the selector they describe.
+    private static final Set<String> SELECTOR_DEPENDENT_SCAN_MODES = ImmutableSet.of(
+            "from-snapshot",
+            "from-snapshot-full",
+            "from-timestamp",
+            "from-timestamp-full",
+            "from-file-creation-time",
+            "from-creation-timestamp");
+
     // FilesScan enumerates the latest partitions before applying its range-aware per-partition scan,
     // so it cannot safely read a range when a partition in that range has since been dropped.
     private static final Set<String> INCREMENTAL_SYSTEM_TABLES = ImmutableSet.of(
@@ -403,6 +426,42 @@ public final class PaimonScanParams {
         return isolatedOptions;
     }
 
+    /**
+     * Strips the time-travel planning selectors — and any derived scan mode Paimon
+     * materialized for them — from a resolved schema's options, keeping every
+     * field and every other option intact. This is the transport-side inverse of
+     * {@link #isolateSnapshotRead(long)}: a statement fence pins the data snapshot by merging
+     * {@code scan.snapshot-id} into the schema options, but the pinned paimon-rust reader
+     * re-resolves a transported selector inside {@code copy_with_time_travel} and swaps the
+     * shipped fields for the pinned snapshot's older schema — a column added after the last
+     * data commit then fails projection before per-file schema evolution can fill it. The rust
+     * reader instead pins data through the serialized DataSplit, so these selectors are
+     * planning-only state and must not be transported.
+     */
+    public static TableSchema withoutTimeTravelSelectors(TableSchema schema) {
+        if (TIME_TRAVEL_SELECTOR_KEYS.stream().noneMatch(schema.options()::containsKey)) {
+            return schema;
+        }
+        Map<String, String> options = new HashMap<>(schema.options());
+        TIME_TRAVEL_SELECTOR_KEYS.forEach(options.keySet()::remove);
+        // Paimon's copyInternal may have materialized the derived scan mode for the
+        // stripped selector; without the selector the rust ReadBuilder validation
+        // ("from-snapshot requires one of scan.snapshot-id, ... to be set") rejects
+        // the open. The reader instead pins data through the serialized DataSplit.
+        // Paimon accepts enum option values case-insensitively but preserves the
+        // original spelling in the options map, so a table can persist
+        // scan.mode=FROM-SNAPSHOT; the mode is normalized before the membership
+        // check because the rust reader recognizes the value case-insensitively and
+        // would reject the now-bare uppercase mode for lacking its selector, while
+        // JNI accepts the original option.
+        String scanMode = options.get(CoreOptions.SCAN_MODE.key());
+        if (scanMode != null && SELECTOR_DEPENDENT_SCAN_MODES.contains(
+                scanMode.trim().toLowerCase(Locale.ROOT))) {
+            options.remove(CoreOptions.SCAN_MODE.key());
+        }
+        return schema.copy(options);
+    }
+
     private static boolean isCompatibleStartupMode(String position, String mode) {
         if ("default".equals(mode)) {
             return true;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java
index 649efdd3e31..a3506783707 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/PaimonUtil.java
@@ -54,6 +54,7 @@ import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.InternalRow;
 import org.apache.paimon.data.Timestamp;
 import org.apache.paimon.data.serializer.InternalRowSerializer;
+import org.apache.paimon.io.DataOutputViewStreamWrapper;
 import org.apache.paimon.manifest.PartitionEntry;
 import org.apache.paimon.options.ConfigOption;
 import org.apache.paimon.partition.Partition;
@@ -64,6 +65,7 @@ import org.apache.paimon.table.DataTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.SpecialFields;
 import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.DataSplit;
 import org.apache.paimon.table.source.ReadBuilder;
 import org.apache.paimon.tag.Tag;
 import org.apache.paimon.types.ArrayType;
@@ -81,6 +83,7 @@ import org.apache.paimon.types.VarCharType;
 import org.apache.paimon.utils.DateTimeUtils;
 import org.apache.paimon.utils.InstantiationUtil;
 import org.apache.paimon.utils.InternalRowPartitionComputer;
+import org.apache.paimon.utils.JsonSerdeUtil;
 import org.apache.paimon.utils.Pair;
 import org.apache.paimon.utils.PartitionPathUtils;
 import org.apache.paimon.utils.Projection;
@@ -728,6 +731,36 @@ public class PaimonUtil {
     private static final class SerializationSizeLimitException extends IOException {
     }
 
+    /**
+     * Serialize DataSplit using Paimon's native binary format.
+     * This format is compatible with paimon-cpp reader.
+     * Uses standard Base64 encoding (not URL-safe) for BE compatibility.
+     */
+    public static String encodeDataSplitToString(DataSplit split) {
+        try {
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            DataOutputViewStreamWrapper out = new DataOutputViewStreamWrapper(baos);
+            split.serialize(out);
+            byte[] bytes = baos.toByteArray();
+            return Base64.getEncoder().encodeToString(bytes);
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to serialize DataSplit using Paimon native format", e);
+        }
+    }
+
+    /**
+     * Serialize a paimon {@link TableSchema} to JSON via Paimon's registered
+     * serde. Output is identical to an on-disk schema/schema-N file and is
+     * consumed by the paimon-rust BE reader through paimon_table_from_schema_json.
+     */
+    public static String encodeTableSchemaToJson(TableSchema tableSchema) {
+        try {
+            return JsonSerdeUtil.toJson(tableSchema);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to serialize paimon TableSchema to JSON", e);
+        }
+    }
+
     public static Map<String, String> getPartitionInfoMap(Table table, BinaryRow partitionValues, String timeZone) {
         return getPartitionInfoMap(table, partitionValues, timeZone, false);
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderCapabilities.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderCapabilities.java
new file mode 100644
index 00000000000..d45ff4ac70c
--- /dev/null
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderCapabilities.java
@@ -0,0 +1,282 @@
+// 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.paimon.source;
+
+import org.apache.doris.analysis.SlotDescriptor;
+import org.apache.doris.analysis.TupleDescriptor;
+
+import com.google.common.collect.ImmutableSet;
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.types.ArrayType;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypeChecks;
+import org.apache.paimon.types.DataTypeRoot;
+import org.apache.paimon.types.DecimalType;
+import org.apache.paimon.types.MapType;
+import org.apache.paimon.types.RowType;
+import org.apache.paimon.types.VarCharType;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/** Compatibility checks for the pinned paimon-rust reader, beyond storage capabilities. */
+final class PaimonRustReaderCapabilities {
+    private static final Set<String> SUPPORTED_AGGREGATE_NAMES = ImmutableSet.of(
+            "sum", "product", "min", "max", "last_value", "first_value", "last_non_null_value",
+            "first_non_null_value", "first_not_null_value", "bool_and", "bool_or", "listagg");
+    private final FileStoreTable table;
+    private final TableSchema schema;
+    private final boolean tableCompatible;
+    private final Map<Long, Boolean> compatibleFileSchemas = new ConcurrentHashMap<>();
+
+    PaimonRustReaderCapabilities(FileStoreTable table, TupleDescriptor tuple) {
+        this.table = table;
+        this.schema = table.schema();
+        this.tableCompatible = schema != null && hasCompatibleSchemaVersion(schema)
+                && hasFullNestedProjection(tuple) && hasCompatibleMergeEngine(schema)
+                && hasCompatibleAggregates(schema);
+    }
+
+    boolean canRead(DataSplit split) {
+        if (!tableCompatible) {
+            return false;
+        }
+        // The pinned merge reader retains losing input batches until an output batch fills.
+        // Neither zero deletes nor a small read.batch-size bounds this across multiple files.
+        if (!schema.primaryKeys().isEmpty() && split.dataFiles().size() > 1) {
+            return false;
+        }
+        for (DataFileMeta file : split.dataFiles()) {
+            // Sort-merge retains consumed batches until it emits enough rows. Retracts can
+            // produce an unbounded zero-output prefix; aggregation also rejects retracts.
+            // Unknown counts must stay on JNI, including old files without this statistic.
+            if (!schema.primaryKeys().isEmpty() && file.deleteRowCount().orElse(-1L) != 0L) {
+                return false;
+            }
+            if (file.schemaId() != schema.id() && !compatibleFileSchemas.computeIfAbsent(
+                    file.schemaId(), this::hasCompatibleFileSchema)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean hasFullNestedProjection(TupleDescriptor tuple) {
+        for (SlotDescriptor slot : tuple.getSlots()) {
+            // The ABI projects only root names, while Arrow struct SerDes bind by ordinal.
+            // A pruned slot must use JNI's recursive read type, even inside arrays or maps.
+            if (slot.getType().isComplexType() && slot.getColumn() != null
+                    && !slot.getType().equals(slot.getColumn().getType())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private boolean hasCompatibleFileSchema(long id) {
+        try {
+            TableSchema fileSchema = table.schemaManager().schema(id);
+            // Match historical types by ID: renames and added fields do not require value casts.
+            return fileSchema != null && hasCompatibleSchemaVersion(fileSchema)
+                    && !hasIncompatibleEvolution(fileSchema.fields(), schema.fields());
+        } catch (RuntimeException e) {
+            // Failure to establish compatibility must not opt a historical file into Rust.
+            return false;
+        }
+    }
+
+    private static boolean hasCompatibleSchemaVersion(TableSchema schema) {
+        // Java defaults a missing JSON version to 1; Rust requires that field when opening
+        // both current and historical schemas. Metadata cannot distinguish an explicit 1.
+        return schema.version() > TableSchema.PAIMON_07_VERSION;
+    }
+
+    private static boolean hasCompatibleMergeEngine(TableSchema schema) {
+        CoreOptions.MergeEngine engine = new CoreOptions(schema.options()).mergeEngine();
+        // Older partial-update/aggregate writers retained repeated INSERT keys within one
+        // file. Zero deletes cannot bound Rust's retained losing batches for those producers.
+        return schema.primaryKeys().isEmpty()
+                || (engine != CoreOptions.MergeEngine.PARTIAL_UPDATE && engine != CoreOptions.MergeEngine.AGGREGATE);
+    }
+
+    private static boolean hasIncompatibleEvolution(List<DataField> oldFields, List<DataField> newFields) {
+        Map<Integer, DataType> oldTypes = new HashMap<>();
+        for (DataField field : oldFields) {
+            oldTypes.put(field.id(), field.type());
+        }
+        for (DataField field : newFields) {
+            DataType oldType = oldTypes.get(field.id());
+            if (oldType != null && hasIncompatibleEvolution(oldType, field.type())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static boolean hasIncompatibleEvolution(DataType oldType, DataType newType) {
+        if (oldType.equalsIgnoreNullable(newType)) {
+            return false;
+        }
+        if (oldType instanceof RowType && newType instanceof RowType) {
+            return hasIncompatibleEvolution(((RowType) oldType).getFields(), ((RowType) newType).getFields());
+        }
+        if (oldType instanceof ArrayType && newType instanceof ArrayType) {
+            return hasIncompatibleEvolution(((ArrayType) oldType).getElementType(),
+                    ((ArrayType) newType).getElementType());
+        }
+        if (oldType instanceof MapType && newType instanceof MapType) {
+            MapType oldMap = (MapType) oldType;
+            MapType newMap = (MapType) newType;
+            return hasIncompatibleEvolution(oldMap.getKeyType(), newMap.getKeyType())
+                    || hasIncompatibleEvolution(oldMap.getValueType(), newMap.getValueType());
+        }
+        DataTypeRoot oldRoot = oldType.getTypeRoot();
+        DataTypeRoot newRoot = newType.getTypeRoot();
+        int oldWidth = integerWidth(oldRoot);
+        int newWidth = integerWidth(newRoot);
+        if (oldWidth > 0) {
+            return !((newWidth >= oldWidth) || newRoot == DataTypeRoot.FLOAT
+                    || newRoot == DataTypeRoot.DOUBLE || newRoot == DataTypeRoot.DECIMAL);
+        }
+        if (oldRoot == DataTypeRoot.FLOAT && newRoot == DataTypeRoot.DOUBLE) {
+            return false;
+        }
+        if (oldRoot == DataTypeRoot.DECIMAL && newRoot == DataTypeRoot.DECIMAL) {
+            return false;
+        }
+        if ((oldRoot == DataTypeRoot.CHAR || oldRoot == DataTypeRoot.VARCHAR)
+                && newRoot == DataTypeRoot.VARCHAR) {
+            return DataTypeChecks.getLength(oldType) > DataTypeChecks.getLength(newType);
+        }
+        if (oldRoot == newRoot && (newRoot == DataTypeRoot.TIMESTAMP_WITHOUT_TIME_ZONE
+                || newRoot == DataTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE)) {
+            int oldPrecision = DataTypeChecks.getPrecision(oldType);
+            int newPrecision = DataTypeChecks.getPrecision(newType);
+            // Arrow's nanosecond range is narrower than historical millis/micros; Java
+            // preserves out-of-range dates while Rust's checked cast turns them into NULL.
+            return oldPrecision > newPrecision || (oldPrecision <= 6 && newPrecision > 6);
+        }
+        // Only established cast equivalence may use Rust. Java's temporal casts use the
+        // default zone and truncate negative epochs; its string casts format/pad values.
+        // Arrow differs there, in numeric narrowing/decimal rounding, and in supported
+        // constructed casts. This allowlist applies at every nested leaf and map key.
+        return true;
+    }
+
+    private static int integerWidth(DataTypeRoot type) {
+        switch (type) {
+            case TINYINT:
+                return 8;
+            case SMALLINT:
+                return 16;
+            case INTEGER:
+                return 32;
+            case BIGINT:
+                return 64;
+            default:
+                return 0;
+        }
+    }
+
+    private static boolean hasCompatibleAggregates(TableSchema schema) {
+        Map<String, String> options = schema.options();
+        CoreOptions.MergeEngine engine = new CoreOptions(options).mergeEngine();
+        if (engine != CoreOptions.MergeEngine.AGGREGATE && engine != CoreOptions.MergeEngine.PARTIAL_UPDATE) {
+            return true;
+        }
+        // Rust validates every configured name before resolving overrides or excluding keys
+        // and sequence fields. Java may accept an unused SPI function that Rust cannot open.
+        for (Map.Entry<String, String> option : options.entrySet()) {
+            String key = option.getKey();
+            if (("fields.default-aggregate-function".equals(key)
+                    || (key.startsWith("fields.") && key.endsWith(".aggregate-function")))
+                    && !SUPPORTED_AGGREGATE_NAMES.contains(option.getValue())) {
+                return false;
+            }
+        }
+        Set<String> sequenceFields = new HashSet<>();
+        if (engine == CoreOptions.MergeEngine.PARTIAL_UPDATE) {
+            for (String key : options.keySet()) {
+                if (key.startsWith("fields.") && key.endsWith(".sequence-group")) {
+                    sequenceFields.addAll(Arrays.asList(key.substring("fields.".length(),
+                            key.length() - ".sequence-group".length()).split(",")));
+                }
+            }
+        }
+        // Validate values and types, not just option keys. Java's SPI includes functions
+        // such as collect which the pinned Rust aggregator factory does not implement.
+        for (DataField field : schema.fields()) {
+            if (schema.primaryKeys().contains(field.name()) || sequenceFields.contains(field.name())) {
+                continue;
+            }
+            String function = options.get("fields." + field.name() + ".aggregate-function");
+            // Partial-update applies the default too, except to keys and sequence fields.
+            // A field override must win before checking the effective function's type support.
+            if (function == null) {
+                function = options.get("fields.default-aggregate-function");
+            }
+            if (function != null && !supportsAggregate(function, field.type())) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean supportsAggregate(String function, DataType type) {
+        DataTypeRoot root = type.getTypeRoot();
+        switch (function) {
+            case "sum":
+                // Java wraps integer arithmetic and retains compact-decimal intermediate
+                // sums beyond declared precision. Rust errors or resets the accumulator.
+                return root == DataTypeRoot.FLOAT || root == DataTypeRoot.DOUBLE
+                        || (type instanceof DecimalType && ((DecimalType) type).getPrecision() > 18);
+            case "product":
+                return root == DataTypeRoot.FLOAT || root == DataTypeRoot.DOUBLE;
+            case "min":
+            case "max":
+                return integerWidth(root) != 0 || root == DataTypeRoot.FLOAT || root == DataTypeRoot.DOUBLE
+                        || root == DataTypeRoot.DECIMAL || root == DataTypeRoot.DATE
+                        || root == DataTypeRoot.TIME_WITHOUT_TIME_ZONE
+                        || root == DataTypeRoot.TIMESTAMP_WITHOUT_TIME_ZONE
+                        || root == DataTypeRoot.CHAR || root == DataTypeRoot.VARCHAR;
+            case "bool_and":
+            case "bool_or":
+                return root == DataTypeRoot.BOOLEAN;
+            case "listagg":
+                return type instanceof VarCharType && ((VarCharType) type).getLength() == Integer.MAX_VALUE;
+            case "last_value":
+            case "first_value":
+            case "last_non_null_value":
+            case "first_non_null_value":
+            case "first_not_null_value":
+                return true;
+            default:
+                return false;
+        }
+    }
+}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java
index 7b5e0e56a63..111bbc905c0 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/paimon/source/PaimonScanNode.java
@@ -53,6 +53,7 @@ import org.apache.doris.planner.ScanContext;
 import org.apache.doris.qe.SessionVariable;
 import org.apache.doris.spi.Split;
 import org.apache.doris.statistics.StatisticalType;
+import org.apache.doris.system.Backend;
 import org.apache.doris.thrift.TExplainLevel;
 import org.apache.doris.thrift.TFileFormatType;
 import org.apache.doris.thrift.TFileRangeDesc;
@@ -63,16 +64,21 @@ import org.apache.doris.thrift.TTableFormatFileDesc;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.Identifier;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.format.OrcOptions;
+import org.apache.paimon.io.DataFileMeta;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.rest.RESTTokenFileIO;
 import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.DataTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.source.DataSplit;
@@ -85,7 +91,7 @@ import org.apache.paimon.table.source.TableScan;
 import org.apache.paimon.table.source.snapshot.SnapshotReader;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataType;
-import org.apache.paimon.types.LocalZonedTimestampType;
+import org.apache.paimon.types.DataTypeRoot;
 import org.apache.paimon.types.MapType;
 import org.apache.paimon.types.MultisetType;
 import org.apache.paimon.types.RowType;
@@ -95,6 +101,7 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
@@ -102,6 +109,7 @@ import java.util.Objects;
 import java.util.Optional;
 import java.util.OptionalInt;
 import java.util.OptionalLong;
+import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.stream.Collectors;
@@ -111,6 +119,7 @@ public class PaimonScanNode extends FileQueryScanNode {
 
     private static final long COUNT_WITH_PARALLEL_SPLITS = 10000;
     private static final long MAX_RETAINED_SERIALIZED_TASK_BYTES = 16L * 1024 * 1024;
+    private volatile PaimonRustReaderCapabilities rustReaderCapabilities;
     private long maxRetainedSerializedTaskBytes = MAX_RETAINED_SERIALIZED_TASK_BYTES;
     // The keys of incremental read params for Paimon SDK
     private static final String PAIMON_INCREMENTAL_BETWEEN = "incremental-between";
@@ -131,11 +140,39 @@ public class PaimonScanNode extends FileQueryScanNode {
             "doris.scan.manifest.parallelism-cap";
     private static final String DORIS_SERIALIZED_SYSTEM_SOURCE = "doris.serialized-system-source";
     private static final String DORIS_SYSTEM_TABLE_TYPE = "doris.system-table-type";
+    // Same key as the rust CoreOptions DELETION_VECTORS_MERGE_ON_READ_OPTION: paimon
+    // 1.4 exposes no Java accessor for it, so the raw TableSchema option is read.
+    private static final String DELETION_VECTORS_MERGE_ON_READ = "deletion-vectors.merge-on-read";
     private static final List<String> BACKEND_PAIMON_OPTIONS = Arrays.asList(
             DORIS_ENABLE_JNI_IO_MANAGER,
             DORIS_JNI_IO_MANAGER_TMP_DIR,
             DORIS_JNI_IO_MANAGER_IMPL_CLASS,
             DORIS_ENABLE_FILE_READER_ASYNC);
+    // The table-location URI schemes whose property translation into the
+    // paimon-rust FileIO key families is implemented (BE bridge) and open
+    // tested: s3 / s3a -> the s3.* family, oss -> the fs.oss.* family, plus
+    // the credential-free hdfs and local-filesystem parsers (hadoop conf and
+    // local paths pass through untouched). See isRustVerifiedLocationScheme.
+    // An hdfs:// location additionally requires the credential-free backend
+    // shape of isRustVerifiedHdfsBackend: the same storage properties carry a
+    // Kerberized catalog's principal / keytab, its proxy user and its HA
+    // nameservice config, none of which the rust HDFS parser can honor.
+    private static final Set<String> RUST_VERIFIED_LOCATION_SCHEMES =
+            new HashSet<>(Arrays.asList("s3", "s3a", "oss", "hdfs", "file"));
+
+    // The dfs./hadoop./fs. keys a credential-free HDFS catalog may transport
+    // without changing what the pinned rust reader does: the warehouse path
+    // identity (fs.defaultFS, always shipped from the location), the
+    // authentication-type markers HdfsProperties always writes (validated to
+    // simple above), and the always-written fallback flag whose semantics only
+    // matter under Kerberos (already rejected above). Every other client
+    // setting is dropped by the rust parser and would diverge from JNI.
+    private static final Set<String> RUST_VERIFIED_HDFS_OPTION_KEYS =
+            new HashSet<>(Arrays.asList(
+                    "fs.defaultFS",
+                    "hadoop.security.authentication",
+                    "hdfs.security.authentication",
+                    "ipc.client.fallback-to-simple-auth-allowed"));
 
     private enum SplitReadType {
         JNI,
@@ -348,6 +385,283 @@ public class PaimonScanNode extends FileQueryScanNode {
         }
     }
 
+    /**
+     * Whether the table location's URI scheme is served by a paimon-rust FileIO parser whose
+     * property translation the FE/BE bridge implements (the s3.* / fs.oss.* key families for
+     * s3 / s3a / oss) or that needs no credentials at all (hdfs hadoop conf and local
+     * filesystem paths). Every other scheme the pinned crate dispatches to its own parser
+     * (cosn / obs / gs / abfs and friends) must fall back to the JNI reader because Doris
+     * delivers those credentials only as AWS_* aliases that those parsers do not read.
+     * A null location cannot be verified (and cannot ship paimon_table either), so it is
+     * not rust-eligible.
+     *
+     * <p>The scheme must also appear in the exact lowercase form the pinned crate consumes.
+     * URI schemes are case-insensitive, but the crate lowercases only its storage
+     * dispatch: its object-store path extraction strips a lowercase {@code s3://} prefix
+     * from the original string, and the hdfs / file helpers likewise match only lowercase
+     * prefixes. A {@code S3://} or {@code Hdfs://} warehouse also produces DataSplit file
+     * paths in that original casing (serialized by the paimon SDK before the FE sees
+     * them), so the mixed-case shape fails the rust open beyond the transported location.
+     * Those valid URI variants must therefore route to JNI, whose Java stack is
+     * case-insensitive everywhere.
+     */
+    @VisibleForTesting
+    static boolean isRustVerifiedLocationScheme(String location) {
+        if (location == null) {
+            return false;
+        }
+        int sep = location.indexOf("://");
+        if (sep <= 0) {
+            // No URI scheme: a plain local path reads through the crate's local-filesystem
+            // parser, which needs no credentials.
+            return true;
+        }
+        return RUST_VERIFIED_LOCATION_SCHEMES.contains(location.substring(0, sep));
+    }
+
+    // Whether the location is an hdfs:// table (the only HDFS-family scheme in
+    // RUST_VERIFIED_LOCATION_SCHEMES; viewfs / jfs never pass it).
+    private static boolean isHdfsLocationScheme(String location) {
+        if (location == null) {
+            return false;
+        }
+        int sep = location.indexOf("://");
+        return sep > 0 && "hdfs".equalsIgnoreCase(location.substring(0, sep));
+    }
+
+    // Whether any member file of the split is ORC. Paimon allows per-level
+    // file.format, so one DataSplit can mix Parquet and ORC files; the split
+    // path's suffix (the first file) cannot speak for the whole split, and the
+    // shifted ORC LTZ decode applies to whichever ORC members rust reads.
+    @VisibleForTesting
+    static boolean splitHasOrcFile(DataSplit dataSplit) {
+        if (dataSplit == null) {
+            return false;
+        }
+        for (DataFileMeta fileMeta : dataSplit.dataFiles()) {
+            String format = fileMeta.fileFormat();
+            if (format != null && "orc".equalsIgnoreCase(format)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // Mirrors the pinned paimon-rust read-mode option matrix
+    // (PartialUpdateConfig::read_unsupported_option_keys and
+    // AggregationConfig's runtime-unsupported keys). Both validate option-key
+    // PRESENCE, not values, so a table carrying the key with an off value is
+    // still rejected by the rust merge construction while Java reads it — the
+    // gate must mirror presence exactly.
+    //
+    // Partial-update reads support basic mode, sequence groups and field
+    // aggregation; unsupported keys are the remove-record-on-delete family,
+    // per-field ignore-delete / ignore-retract / distinct / nested-key /
+    // count-limit options.
+    private static boolean isRustUnsupportedPartialUpdateReadOption(String key) {
+        return (key.endsWith(".ignore-delete")
+                && !"ignore-delete".equals(key)
+                && !"partial-update.ignore-delete".equals(key))
+                || "partial-update.remove-record-on-delete".equals(key)
+                || "partial-update.remove-record-on-sequence-group".equals(key)
+                || hasFieldOptionSuffix(key, ".ignore-retract")
+                || hasFieldOptionSuffix(key, ".distinct")
+                || hasFieldOptionSuffix(key, ".nested-key")
+                || hasFieldOptionSuffix(key, ".count-limit");
+    }
+
+    // Aggregation reads support the per-field aggregate-function /
+    // list-agg-delimiter / default-aggregate-function matrix; unsupported keys
+    // are the remove-record-on-delete family, every ignore-delete spelling
+    // (including the bare one), and per-field sequence-group / ignore-retract /
+    // distinct / nested-key / count-limit options.
+    private static boolean isRustUnsupportedAggregationRuntimeOption(String key) {
+        return "ignore-delete".equals(key)
+                || key.endsWith(".ignore-delete")
+                || "aggregation.remove-record-on-delete".equals(key)
+                || hasFieldOptionSuffix(key, ".sequence-group")
+                || hasFieldOptionSuffix(key, ".ignore-retract")
+                || hasFieldOptionSuffix(key, ".distinct")
+                || hasFieldOptionSuffix(key, ".nested-key")
+                || hasFieldOptionSuffix(key, ".count-limit");
+    }
+
+    private static boolean hasFieldOptionSuffix(String key, String suffix) {
+        return key.startsWith("fields.") && key.endsWith(suffix);
+    }
+
+    // Whether the schema options carry any option key the pinned paimon-rust
+    // read rejects for this merge engine.
+    @VisibleForTesting
+    static boolean hasRustUnsupportedMergeOption(Map<String, String> options,
+            CoreOptions.MergeEngine mergeEngine) {
+        boolean partialUpdate = mergeEngine == CoreOptions.MergeEngine.PARTIAL_UPDATE;
+        for (String key : options.keySet()) {
+            if (key == null) {
+                continue;
+            }
+            if (partialUpdate ? isRustUnsupportedPartialUpdateReadOption(key)
+                    : isRustUnsupportedAggregationRuntimeOption(key)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // Whether any member file of the split carries a data-file.external-paths
+    // location. Paimon can store an absolute location in each DataFileMeta, and
+    // both Java and the serialized rust split prefer it over the bucket path —
+    // but the pinned rust table builds ONE FileIO from paimon_table, whose
+    // storage enum parses every file with that warehouse-selected backend: an
+    // admitted hdfs table with an s3:// external file (or an s3 table with an
+    // oss:// file) reaches the wrong parser and fails the open, while JNI
+    // reads it. The shipped options describe only the warehouse, so any
+    // external file keeps the split on JNI.
+    @VisibleForTesting
+    static boolean splitHasExternalFiles(DataSplit dataSplit) {
+        if (dataSplit == null) {
+            return false;
+        }
+        for (DataFileMeta fileMeta : dataSplit.dataFiles()) {
+            if (fileMeta.externalPath().isPresent()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    // Native and Rust ORC routing must share this recursive check: nested LTZ values
+    // require the same JVM-zone conversion, including elements of a MULTISET.
+    @VisibleForTesting
+    static boolean containsTimestampLtz(DataType type) {
+        if (type == null) {
+            return false;
+        }
+        if (type.getTypeRoot() == DataTypeRoot.TIMESTAMP_WITH_LOCAL_TIME_ZONE) {
+            return true;
+        }
+        if (type instanceof ArrayType) {
+            return containsTimestampLtz(((ArrayType) type).getElementType());
+        }
+        if (type instanceof MapType) {
+            MapType mapType = (MapType) type;
+            return containsTimestampLtz(mapType.getKeyType())
+                    || containsTimestampLtz(mapType.getValueType());
+        }
+        if (type instanceof MultisetType) {
+            return containsTimestampLtz(((MultisetType) type).getElementType());
+        }
+        if (type instanceof RowType) {
+            return ((RowType) type).getFields().stream()
+                    .anyMatch(field -> containsTimestampLtz(field.type()));
+        }
+        return false;
+    }
+
+    /**
+     * Whether an HDFS catalog's shipped backend properties describe a shape the
+     * pinned paimon-rust HDFS reader can serve identically to JNI. The rust
+     * storage_hdfs parser reads only the hdfs.name-node / hdfs.enable-append
+     * keys — no kerberos, no proxy user, no HA resolution, and no Hadoop client
+     * option map (HdfsNativeConfig.options stays empty) — so a catalog whose
+     * settings need any of those would open as the BE process's ambient
+     * identity, resolve the wrong DataNode, or miss the catalog's configured
+     * access while the same query works through JNI. Only the open-tested
+     * credential-free shape stays rust-eligible: simple (or unset)
+     * authentication, no principal / keytab / proxy user, no HA nameservice
+     * resolution, and no client option beyond the always-shipped inert keys of
+     * {@link #RUST_VERIFIED_HDFS_OPTION_KEYS} — everything else in the
+     * dfs./hadoop./fs. namespaces is dropped by the rust parser and keeps the
+     * catalog on JNI (dfs.client.use.datanode.hostname=true is the canonical
+     * simple-auth example: hdfs-native defaults to false and connects to the
+     * DataNode's advertised IP instead of its hostname, which commonly fails
+     * behind containers/NAT).
+     */
+    @VisibleForTesting
+    static boolean isRustVerifiedHdfsBackend(Map<String, String> backendStorageProperties) {
+        if (backendStorageProperties == null) {
+            return true;
+        }
+        // Authentication type: "simple" (or unset) authenticates as the same
+        // ambient OS user on both readers; kerberos (or anything else) needs the
+        // channel the rust parser does not read.
+        for (String authKey : new String[] {"hadoop.security.authentication",
+                "hdfs.security.authentication"}) {
+            String value = backendStorageProperties.get(authKey);
+            if (value != null && !"simple".equalsIgnoreCase(value.trim())) {
+                return false;
+            }
+        }
+        // Kerberos identity and the proxy user: any of these configured means
+        // the open must carry a specific identity, which only JNI can honor.
+        for (String identityKey : new String[] {"hadoop.kerberos.principal",
+                "hadoop.kerberos.keytab", "hadoop.username"}) {
+            String value = backendStorageProperties.get(identityKey);
+            if (value != null && !value.trim().isEmpty()) {
+                return false;
+            }
+        }
+        // HA nameservice resolution: the rust parser receives no dfs.* config,
+        // so a nameservice-authority location (dfs.nameservices / dfs.ha.*)
+        // cannot be resolved; the proven shape is a single name-node URI.
+        String nameServices = backendStorageProperties.get("dfs.nameservices");
+        if (nameServices != null && !nameServices.trim().isEmpty()) {
+            return false;
+        }
+        for (Map.Entry<String, String> entry : backendStorageProperties.entrySet()) {
+            if (entry.getKey() != null && entry.getKey().startsWith("dfs.ha.")
+                    && entry.getValue() != null && !entry.getValue().trim().isEmpty()) {
+                return false;
+            }
+        }
+        // Client options: the pinned rust storage_hdfs parser reads only the
+        // hdfs.name-node / hdfs.enable-append keys and leaves
+        // HdfsNativeConfig.options empty, so any other transported dfs./hadoop./fs.
+        // setting is silently dropped and hdfs-native runs with its own
+        // defaults. dfs.client.use.datanode.hostname=true is the canonical
+        // simple-auth example: valid without Kerberos, but hdfs-native
+        // defaults to false and connects to the DataNode's advertised IP
+        // instead of its hostname, which commonly fails behind containers/NAT
+        // while JNI succeeds. Only the keys HdfsProperties always writes for a
+        // credential-free catalog (or already validated above) are proven
+        // inert; anything else in these namespaces keeps the catalog on JNI.
+        for (Map.Entry<String, String> entry : backendStorageProperties.entrySet()) {
+            String key = entry.getKey();
+            // A blank value is the unset case (the producer null-filters; a
+            // blank site-config entry is inert), matching the identity and HA
+            // checks above.
+            if (key == null || entry.getValue() == null || entry.getValue().trim().isEmpty()) {
+                continue;
+            }
+            if ((key.startsWith("dfs.") || key.startsWith("hadoop.") || key.startsWith("fs."))
+                    && !RUST_VERIFIED_HDFS_OPTION_KEYS.contains(key)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * Mirrors the pinned paimon-rust {@code DataSplit::is_fully_materialized_pk_dv}: a
+     * primary-key split is safe to read raw under deletion vectors only when scan planning
+     * marked it raw convertible AND every data file is compacted (level != 0) and known to
+     * carry no retract rows (delete_row_count == Some(0)). The rust read_pk path requires
+     * this for partial-update / aggregation DV tables; anything weaker must stay on JNI.
+     */
+    @VisibleForTesting
+    static boolean isFullyMaterializedPkDvSplit(DataSplit dataSplit) {
+        if (!dataSplit.rawConvertible()) {
+            return false;
+        }
+        for (DataFileMeta fileMeta : dataSplit.dataFiles()) {
+            Optional<Long> deleteRowCount = fileMeta.deleteRowCount();
+            if (fileMeta.level() == 0 || !deleteRowCount.isPresent() || deleteRowCount.get() != 0L) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     private List<String> getOrderedPathPartitionKeys() {
         if (source == null) {
             return Collections.emptyList();
@@ -432,10 +746,348 @@ public class PaimonScanNode extends FileQueryScanNode {
 
         String fileFormat = getFileFormat(paimonSplit.getPathString());
         if (split != null) {
+            // use jni reader / paimon-cpp reader / paimon-rust reader
             rangeDesc.setFormatType(TFileFormatType.FORMAT_JNI);
-            // A logical DataSplit may span multiple files, so keep it intact for the JNI reader.
-            fileDesc.setReaderType(TPaimonReaderType.PAIMON_JNI);
-            fileDesc.setPaimonSplit(PaimonUtil.encodeObjectToString(split));
+            // paimon-cpp and paimon-rust both consume Paimon native binary serialization,
+            // which only supports DataSplit. Any other split type falls back to JNI.
+            boolean nativeSplit = split instanceof DataSplit;
+            // Fallback-read splits stay on JNI: FallbackDataSplit extends
+            // DataSplit, so the instanceof above passes, but its serializer
+            // appends an isFallback byte after the ordinary split that the
+            // pinned rust decoder rejects outright ("trailing bytes after
+            // DataSplit" — it requires full-buffer consumption), and even a
+            // permissive decode would still lack the second table identity
+            // needed to honor the fallback-side discriminator. Both sides of a
+            // FallbackReadFileStoreTable wrap their splits, so the table
+            // wrapper is gated as a whole (any split from it routes to JNI)
+            // until the rust ABI represents both sides; the FallbackSplit
+            // interface also catches a wrapper split regardless of how the
+            // table was resolved here.
+            boolean fallbackRead = split instanceof FallbackReadFileStoreTable.FallbackSplit
+                    || processedTable instanceof FallbackReadFileStoreTable;
+            // Serialize the same effective table that planning and the JNI reader use.
+            // Relation options such as t@options('read.batch-size'='1') are applied by
+            // getProcessedTable() (doInitialize caches it in processedTable), and the
+            // rust reader derives its read batch size from the schema options — the raw
+            // cached table would silently drop the override. Copies, delegates and
+            // fallback wrappers of getProcessedTable() are still FileStoreTable, so the
+            // instanceof gate keeps its semantics.
+            Table paimonTable = processedTable;
+            FileStoreTable paimonFileStoreTable =
+                    paimonTable instanceof FileStoreTable ? (FileStoreTable) paimonTable : null;
+            // query-auth.enabled tables stay on JNI: when catalog authorization
+            // succeeds with no row filter or column mask, Paimon still leaves an
+            // ordinary DataSplit (restricted results use QueryAuthSplit and are
+            // already handled by the nativeSplit gate above), so this table shape
+            // passes the compound gate — but the shipped schema keeps
+            // query-auth.enabled=true and the pinned rust ReadBuilder rejects
+            // every such table (its CoreOptions::ensure_read_authorized fails
+            // closed because the client cannot enforce the row filter / column
+            // masking), turning a valid authorized scan into a BE-open failure.
+            // Until the authorization result can be transported and enforced by
+            // the rust ABI, these tables route to JNI.
+            boolean queryAuthTable = false;
+            // REST-token tables stay on JNI: doInitialize snapshots
+            // RESTTokenFileIO.validToken().token() into the backend storage
+            // properties, discarding expireAtMillis and the REST refresh
+            // context, so the shipped credentials look static — but the
+            // pinned rust table reuses one option map with no refresh
+            // callback, while paimon 1.4.2's JNI RESTTokenFileIO checks
+            // expiry before each file operation and obtains a replacement
+            // token. A queued or long scan that crosses the token TTL would
+            // start on rust and later fail authentication. Gate until the
+            // rust ABI can refresh and atomically update credentials.
+            boolean restTokenTable = false;
+            // Partial-update / aggregation tables with deletion vectors only pass
+            // the rust reader in the fully materialized shape: the pinned rust
+            // read_pk rejects merge-engine=partial-update/aggregation with
+            // deletion-vectors.merge-on-read=true outright, and otherwise requires
+            // every split to be compacted and known free of retract rows
+            // (DataSplit::is_fully_materialized_pk_dv). Their ordinary DataSplits
+            // sail through the compound gate above, so without this check a valid
+            // Java/JNI scan reaches BE and the rust open fails. Deduplicate stays
+            // rust-eligible: its read_pk routes uncompacted splits to the KV
+            // reader, which applies the attached per-file DVs. merge-on-read=true
+            // is a table option, so the whole table routes to JNI;
+            // non-materialized splits are gated per split below.
+            boolean puAggDeletionVectors = false;
+            boolean dvMergeOnRead = false;
+            boolean deduplicateIgnoreDelete = false;
+            boolean rustUnsupportedMergeOption = false;
+            if (paimonFileStoreTable != null) {
+                // A renewable REST token reached the shipped properties as a
+                // plain value; only the table's FileIO type reveals it expires.
+                // Null-safe: a table handle whose FileIO is not resolved stays
+                // rust-eligible, mirroring the CoreOptions null-safety below.
+                restTokenTable = paimonFileStoreTable.fileIO() instanceof RESTTokenFileIO;
+                CoreOptions resolvedCoreOptions = paimonFileStoreTable.coreOptions();
+                // Null-safe: a table handle whose CoreOptions is not resolved
+                // (e.g. some wrapper shapes) stays rust-eligible rather than
+                // failing the scan here — the rust open itself rejects such a
+                // table if the option is really set.
+                if (resolvedCoreOptions != null) {
+                    queryAuthTable = resolvedCoreOptions.queryAuthEnabled();
+                    CoreOptions.MergeEngine mergeEngine = resolvedCoreOptions.mergeEngine();
+                    if (resolvedCoreOptions.deletionVectorsEnabled()
+                            && (mergeEngine == CoreOptions.MergeEngine.PARTIAL_UPDATE
+                                    || mergeEngine == CoreOptions.MergeEngine.AGGREGATE)) {
+                        puAggDeletionVectors = true;
+                        // The merge-engine and deletion-vectors.enabled checks
+                        // above resolve through the Java CoreOptions accessors,
+                        // which the table builds from this same schema options
+                        // map — the one the BE rust reader deserializes from
+                        // the shipped schema JSON — so they cannot diverge from
+                        // what BE sees. merge-on-read has no Java accessor in
+                        // paimon 1.4, so it is read raw from the map, with the
+                        // rust parsing semantics (any case-insensitive "true"
+                        // is on, default false).
+                        TableSchema dvSchema = paimonFileStoreTable.schema();
+                        Map<String, String> dvOptions = dvSchema == null ? null : dvSchema.options();
+                        String mergeOnRead = dvOptions == null
+                                ? null : dvOptions.get(DELETION_VECTORS_MERGE_ON_READ);
+                        dvMergeOnRead = "true".equalsIgnoreCase(mergeOnRead);
+                    }
+                    // deduplicate.ignore-delete=true tables stay on JNI:
+                    // Java's DeduplicateMergeFunction skips retract records
+                    // when the option is set — including old, uncompacted
+                    // files that still contain them — but the pinned rust
+                    // read_pk does not pass table options into its
+                    // deduplicate merge: it picks the latest row and omits
+                    // the key when that row is DELETE/UPDATE_BEFORE. An
+                    // uncompacted insert followed by a delete therefore
+                    // returns the insert through JNI but silently disappears
+                    // through rust. Gate the option until the rust merge
+                    // implements it.
+                    if (mergeEngine == CoreOptions.MergeEngine.DEDUPLICATE
+                            && resolvedCoreOptions.ignoreDelete()) {
+                        deduplicateIgnoreDelete = true;
+                    }
+                    // Non-DV merge options the pinned rust read rejects: Java
+                    // supports partial-update.remove-record-on-delete /
+                    // aggregation.remove-record-on-delete and the wider
+                    // per-field retract matrix, but the rust
+                    // PartialUpdateConfig / AggregationConfig validations
+                    // return Unsupported for them — and the DV-derived gates
+                    // above only cover deletion-vector tables, so an ordinary
+                    // non-DV DataSplit with one of these options would pass the
+                    // compound gate and fail during the rust merge
+                    // construction. Mirror the exact rust key matrix (presence,
+                    // not values) against the same schema options map BE
+                    // deserializes.
+                    if (mergeEngine == CoreOptions.MergeEngine.PARTIAL_UPDATE
+                            || mergeEngine == CoreOptions.MergeEngine.AGGREGATE) {
+                        TableSchema mergeSchema = paimonFileStoreTable.schema();
+                        Map<String, String> mergeOptions =
+                                mergeSchema == null ? null : mergeSchema.options();
+                        rustUnsupportedMergeOption = mergeOptions != null
+                                && hasRustUnsupportedMergeOption(mergeOptions, mergeEngine);
+                    }
+                }
+            }
+            // paimon-rust additionally requires (a) FileScannerV2: the V1 FileScanner
+            // explicitly rejects PAIMON_RUST, so with enable_file_scanner_v2 disabled
+            // the split falls back to JNI instead of encoding a rust request that the
+            // selected scanner cannot consume, and (b) a FileStoreTable: BE opens the
+            // table via paimon_table_from_schema_json, which needs the resolved
+            // TableSchema that only FileStoreTable exposes via schema(). If the table
+            // is not a FileStoreTable (e.g. a sys table backed by DataSplit), we cannot
+            // ship a schema JSON, so fall back to CPP / JNI rather than sending an
+            // incomplete PAIMON_RUST request that BE would reject.
+            //
+            // The paimon-rust S3 bridge maps static credentials, anonymous
+            // access (AWS_CREDENTIALS_PROVIDER_TYPE=ANONYMOUS -> s3.anonymous)
+            // and assume-role (AWS_ROLE_ARN / AWS_EXTERNAL_ID ->
+            // s3.assumed.role.*), but the remaining credential-provider modes
+            // are ambient JVM provider chains (ENV, SYSTEM_PROPERTIES,
+            // WEB_IDENTITY, CONTAINER, INSTANCE_PROFILE) with no paimon-rust
+            // equivalent — rust would silently sign with whatever the ambient
+            // chain resolves to. Gate those modes away from the rust reader
+            // here so the configured provider is honored via the JNI path.
+            boolean providerModeTranslatable = true;
+            String providerType = backendStorageProperties == null
+                    ? null : backendStorageProperties.get("AWS_CREDENTIALS_PROVIDER_TYPE");
+            String mode = providerType == null ? "DEFAULT" : providerType.trim().toUpperCase(Locale.ROOT);
+            if (mode.isEmpty()) {
+                mode = "DEFAULT";
+            }
+            String location = source.getTableLocation();
+            if (location != null && (location.startsWith("s3://") || location.startsWith("s3a://"))) {
+                // Java DEFAULT may resolve JVM properties or anonymous credentials;
+                // Rust's ambient chain is different. Only explicit keys or explicit
+                // anonymous access can cross this boundary without changing identity.
+                String accessKey = backendStorageProperties == null
+                        ? null : backendStorageProperties.get("AWS_ACCESS_KEY");
+                String secretKey = backendStorageProperties == null
+                        ? null : backendStorageProperties.get("AWS_SECRET_KEY");
+                boolean staticKeys = accessKey != null && !accessKey.trim().isEmpty()
+                        && secretKey != null && !secretKey.trim().isEmpty();
+                // Hadoop's Simple provider gives static keys precedence over anonymous,
+                // role and token settings. Rust interprets those settings differently.
+                boolean conflictingStaticSettings = staticKeys && (mode.equals("ANONYMOUS")
+                        || StringUtils.isNotEmpty(backendStorageProperties.get("AWS_ROLE_ARN"))
+                        || StringUtils.isNotEmpty(backendStorageProperties.get("AWS_TOKEN")));
+                providerModeTranslatable = !conflictingStaticSettings
+                        && (mode.equals("ANONYMOUS") || (mode.equals("DEFAULT") && staticKeys));
+            } else if (providerType != null) {
+                providerModeTranslatable = mode.equals("DEFAULT") || mode.equals("ANONYMOUS");
+                // OSS has no anonymous FileIO mode in the pinned Rust dependency.
+                if (mode.equals("ANONYMOUS") && location != null && location.startsWith("oss://")) {
+                    providerModeTranslatable = false;
+                }
+            }
+            // Incremental scans (binlog / changelog / delta / diff) must stay
+            // on the JNI path: this wire format carries only an ordinary
+            // DataSplit and the rust reader invokes TableRead::to_arrow, but
+            // paimon 1.4 marks incremental splits as streaming (which the
+            // pinned rust deserializer rejects), diff requires a separate
+            // IncrementalPlan instead of an ordinary plan, and ordinary
+            // primary-key reads can merge versions rather than return the
+            // changes — until the C ABI transports the mode and plan, the
+            // rust reader cannot express any of these.
+            TableScanParams incrementalParams = getScanParams();
+            boolean isIncremental = incrementalParams != null && incrementalParams.incrementalRead();
+            // ORC TIMESTAMP_WITH_LOCAL_TIME_ZONE schemas stay on JNI: the pinned
+            // paimon-rust ORC decoder materializes LTZ instants shifted by the
+            // writer timezone (an upstream crate limitation), so a logical ORC
+            // DataSplit that selects rust (e.g. with force_jni_scanner=true or
+            // when raw conversion is unavailable) returns a different instant
+            // than JNI — applying the session timezone in BE cannot repair an
+            // epoch already shifted during decode. Two bypasses are covered:
+            // (a) the format must come from EVERY member file — paimon allows
+            // per-level file.format, so one DataSplit can mix Parquet and ORC
+            // files and the split path's suffix (the first file) would hide
+            // the ORC members; (b) the LTZ search must recurse into nested
+            // types — an LTZ under MAP/ARRAY/ROW reaches the same shifted ORC
+            // decode through the container's field materialization. Parquet
+            // files with any LTZ, and ORC without any recursive LTZ, stay
+            // rust-eligible. nativeSplit only guards the cast — non-DataSplit
+            // splits already route to JNI.
+            boolean orcLtzSchema = paimonFileStoreTable != null
+                    && nativeSplit
+                    && splitHasOrcFile((DataSplit) split)
+                    && paimonFileStoreTable.schema().fields().stream()
+                            .anyMatch(field -> containsTimestampLtz(field.type()));
+            // data-file.external-paths splits stay on JNI (see
+            // splitHasExternalFiles): the rust table's single FileIO cannot
+            // serve an external file's backend. nativeSplit only guards the
+            // cast — non-DataSplit splits already route to JNI.
+            boolean externalFileSplit = nativeSplit && splitHasExternalFiles((DataSplit) split);
+            // Projected VARIANT columns stay on JNI: the rust leaf feeds its
+            // Arrow arrays to the slot serdes, and DataTypeVariantV2SerDe::
+            // read_column_from_arrow unconditionally returns
+            // NOT_IMPLEMENTED_ERROR — a nested Variant (ARRAY / MAP / STRUCT
+            // containing one) reaches the same decoder through the container
+            // serdes. desc carries only the slots this query projects, so a
+            // table whose VARIANT column is not projected still scans on
+            // rust. Gate until the rust leaf has a Variant Arrow decoder.
+            boolean projectedVariant = desc.getSlots().stream()
+                    .anyMatch(slot -> PaimonUtil.containsVariant(slot.getType()));
+            // Scheme capability gate: the pinned paimon-rust storage
+            // dispatcher (io/storage.rs) selects the FileIO parser from the
+            // table location's URI scheme, and libpaimon_c.a compiles in
+            // separate COS, OBS, GCS and Azdls parsers besides the OSS and S3
+            // ones. Doris normalizes every object store's credentials into
+            // the AWS_* / use_path_style aliases (see the *Properties storage
+            // classes), which the BE rust bridge translates only into the
+            // fs.oss.* and s3.* key families — a cosn:// / obs:// / gs:// /
+            // abfs:// warehouse would reach its scheme's parser without the
+            // key family it reads (fs.cosn.userinfo.*, fs.obs.*, gcs.*,
+            // azure.*) and fail the open instead of using JNI. Only the
+            // schemes whose property translation is implemented and
+            // open-tested (s3 / s3a / oss, via RUST_VERIFIED_LOCATION_SCHEMES)
+            // plus the credential-free hdfs and local-filesystem parsers stay
+            // rust-eligible; every other scheme falls back to JNI. A null
+            // location also routes to JNI: the rust path needs the
+            // paimon_table that only a real location can provide (BE rejects
+            // a split without it).
+            boolean schemeCapabilityVerified = isRustVerifiedLocationScheme(source.getTableLocation());
+            // An hdfs:// location is scheme-verified only together with the credential-free
+            // backend shape: the backend storage properties that ship to BE also carry an
+            // HDFS catalog's authentication (kerberos principal / keytab, proxy user, HA
+            // nameservice config), none of which the pinned rust HDFS parser reads — the
+            // scan would open as the BE process's ambient identity instead of the
+            // catalog's configured one and fail the access JNI honors. See
+            // isRustVerifiedHdfsBackend.
+            boolean hdfsBackendVerified = !isHdfsLocationScheme(source.getTableLocation())
+                    || isRustVerifiedHdfsBackend(backendStorageProperties);
+            // With merge-on-read=true the whole table already routes to JNI (dvMergeOnRead);
+            // for the remaining partial-update/aggregation DV tables, a split that is
+            // not fully materialized (uncompacted level-0 data, or retractions not
+            // known to be applied — even a split with no deletion file attached yet)
+            // fails the rust is_fully_materialized_pk_dv guard, so it falls back per
+            // split instead of turning into a BE-open failure. nativeSplit and
+            // !fallbackRead only guard the cast — those splits already route to JNI.
+            boolean splitDvNotMaterialized = puAggDeletionVectors && !dvMergeOnRead
+                    && nativeSplit && !fallbackRead
+                    && !isFullyMaterializedPkDvSplit((DataSplit) split);
+            boolean canUseRust = sessionVariable.isEnablePaimonRustReader()
+                    && sessionVariable.enableFileScannerV2 && nativeSplit && !fallbackRead
+                    && !isIncremental && providerModeTranslatable && !queryAuthTable
+                    && !restTokenTable
+                    && !dvMergeOnRead && !splitDvNotMaterialized
+                    && !orcLtzSchema && !projectedVariant && !externalFileSplit
+                    && !deduplicateIgnoreDelete && !rustUnsupportedMergeOption
+                    && schemeCapabilityVerified && hdfsBackendVerified
+                    && paimonFileStoreTable != null;
+            if (canUseRust) {
+                // Branch tables can retain persisted modes without a selector. Validate the
+                // transported options too: the pinned Rust builder only accepts default here.
+                String scanMode = PaimonScanParams.withoutTimeTravelSelectors(paimonFileStoreTable.schema())
+                        .options().get(CoreOptions.SCAN_MODE.key());
+                canUseRust = scanMode == null || "default".equalsIgnoreCase(scanMode);
+            }
+            if (canUseRust) {
+                // Every candidate can receive this range; older BEs cannot decode reader type 3.
+                canUseRust = !backendPolicy.getBackends().isEmpty()
+                        && backendPolicy.getBackends().stream().allMatch(Backend::isPaimonRustReaderSupported);
+            }
+            if (canUseRust) {
+                if (rustReaderCapabilities == null) {
+                    rustReaderCapabilities = new PaimonRustReaderCapabilities(paimonFileStoreTable, desc);
+                }
+                canUseRust = rustReaderCapabilities.canRead((DataSplit) split);
+            }
+            if (canUseRust) {
+                fileDesc.setReaderType(TPaimonReaderType.PAIMON_RUST);
+                fileDesc.setPaimonSplit(PaimonUtil.encodeDataSplitToString((DataSplit) split));
+            } else {
+                // A logical DataSplit may span multiple files, so keep it intact for the JNI reader.
+                fileDesc.setReaderType(TPaimonReaderType.PAIMON_JNI);
+                fileDesc.setPaimonSplit(PaimonUtil.encodeObjectToString(split));
+            }
+            // Set table location for paimon-cpp / paimon-rust reader
+            String tableLocation = source.getTableLocation();
+            if (tableLocation != null) {
+                fileDesc.setPaimonTable(tableLocation);
+            }
+            // paimon-rust reader opens tables via paimon_table_from_schema_json:
+            // ship db/table + resolved TableSchema JSON + non-default branch.
+            if (canUseRust) {
+                ExternalTable extTable = source.getExternalTable();
+                fileDesc.setDbName(extTable.getDbName());
+                fileDesc.setTableName(extTable.getName());
+
+                // No catalog / warehouse needed. FE ships the resolved TableSchema JSON
+                // and the branch (null-if-main; matches upstream paimon commit 742da63)
+                // so BE can skip a schema-file round trip. The FileStoreTable cast is
+                // safe here because canUseRust gates on it above. The fence's time-travel
+                // selector is stripped before transport: the rust reader pins data via
+                // the serialized DataSplit, and a shipped selector (scan.snapshot-id) is
+                // re-resolved by paimon-rust's copy_with_time_travel, which would swap
+                // these resolved fields for the pinned snapshot's older schema — a
+                // column added after the last data commit would then fail projection
+                // before per-file schema evolution could fill it (JNI keeps the resolved
+                // schema, so stripping restores rust/JNI parity).
+                TableSchema tableSchema =
+                        PaimonScanParams.withoutTimeTravelSelectors(
+                                ((FileStoreTable) paimonTable).schema());
+                fileDesc.setPaimonTableSchemaJson(PaimonUtil.encodeTableSchemaToJson(tableSchema));
+
+                String branch = CoreOptions.branch(tableSchema.options());
+                if (!Identifier.DEFAULT_MAIN_BRANCH.equals(branch)) {
+                    fileDesc.setPaimonBranch(branch);
+                }
+            }
             rangeDesc.setSelfSplitWeight(paimonSplit.getSelfSplitWeight());
         } else {
             // use native reader
@@ -584,6 +1236,9 @@ public class PaimonScanNode extends FileQueryScanNode {
                 pushDownCountSplits.add(split);
                 pushDownCountSum += count;
             } else if (!forceJniScanner && !forceJniForSystemTable && supportNativeReader(optRawFiles)) {
+                // Keep raw-file reads ahead of logical JNI/Rust dispatch: only the native file
+                // reader can use footer aggregates for COUNT(col) and MIN/MAX. Logical DataSplits
+                // may require merge/delete semantics and cannot reuse independent file statistics.
                 if (ignoreSplitType == SessionVariable.IgnoreSplitType.IGNORE_NATIVE) {
                     continue;
                 }
@@ -1099,26 +1754,6 @@ public class PaimonScanNode extends FileQueryScanNode {
         return true;
     }
 
-    private static boolean containsTimestampLtz(DataType type) {
-        if (type instanceof LocalZonedTimestampType) {
-            return true;
-        }
-        if (type instanceof ArrayType) {
-            return containsTimestampLtz(((ArrayType) type).getElementType());
-        }
-        if (type instanceof MapType) {
-            MapType map = (MapType) type;
-            return containsTimestampLtz(map.getKeyType()) || containsTimestampLtz(map.getValueType());
-        }
-        if (type instanceof MultisetType) {
-            return containsTimestampLtz(((MultisetType) type).getElementType());
-        }
-        if (type instanceof RowType) {
-            return ((RowType) type).getFieldTypes().stream().anyMatch(PaimonScanNode::containsTimestampLtz);
-        }
-        return false;
-    }
-
     @Override
     public TFileFormatType getFileFormatType() throws DdlException, MetaNotFoundException {
         return TFileFormatType.FORMAT_JNI;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index bfb5b358b99..495aa82d96b 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -821,6 +821,8 @@ public class SessionVariable implements Serializable, Writable {
 
     public static final String FORCE_JNI_SCANNER = "force_jni_scanner";
 
+    public static final String ENABLE_PAIMON_RUST_READER = "enable_paimon_rust_reader";
+
     public static final String ENABLE_COUNT_PUSH_DOWN_FOR_EXTERNAL_TABLE = "enable_count_push_down_for_external_table";
 
     public static final String FETCH_ALL_FE_FOR_SYSTEM_TABLE = "fetch_all_fe_for_system_table";
@@ -3147,6 +3149,12 @@ public class SessionVariable implements Serializable, Writable {
             description = {"强制使用 jni 方式读取外表", "Force the use of jni mode to read external table"})
     private boolean forceJniScanner = false;
 
+    @VariableMgr.VarAttr(name = ENABLE_PAIMON_RUST_READER,
+            fuzzy = true,
+            description = {"Paimon 非原生文件读取使用 paimon-rust,优先级高于 paimon-cpp",
+                    "Use paimon-rust for non-native Paimon reads, higher priority than paimon-cpp"})
+    private boolean enablePaimonRustReader = false;
+
     @VariableMgr.VarAttr(name = ENABLE_COUNT_PUSH_DOWN_FOR_EXTERNAL_TABLE,
             fuzzy = true,
             description = {"对外表启用 count(*) 下推优化", "enable count(*) pushdown optimization for external table"})
@@ -4076,6 +4084,8 @@ public class SessionVariable implements Serializable, Writable {
 
         // jni
         this.forceJniScanner = random.nextBoolean();
+        this.enablePaimonRustReader = random.nextBoolean();
+
         // statistics
         this.fetchHiveRowCountSync = random.nextBoolean();
 
@@ -5875,6 +5885,7 @@ public class SessionVariable implements Serializable, Writable {
         tResult.setEnableOrcFilterByMinMax(enableOrcFilterByMinMax);
         tResult.setEnableExprZonemapFilter(enableExprZonemapFilter);
         tResult.setFilePresignedUrlTtlSeconds(filePresignedUrlTtlSeconds);
+        tResult.setEnablePaimonRustReader(enablePaimonRustReader);
         tResult.setCheckOrcInitSargsSuccess(checkOrcInitSargsSuccess);
 
         tResult.setTruncateCharOrVarcharColumns(truncateCharOrVarcharColumns);
@@ -6704,6 +6715,10 @@ public class SessionVariable implements Serializable, Writable {
         return forceJniScanner;
     }
 
+    public boolean isEnablePaimonRustReader() {
+        return enablePaimonRustReader;
+    }
+
     public String getIgnoreSplitType() {
         return ignoreSplitType;
     }
@@ -6727,6 +6742,10 @@ public class SessionVariable implements Serializable, Writable {
         forceJniScanner = force;
     }
 
+    public void setEnablePaimonRustReader(boolean enable) {
+        enablePaimonRustReader = enable;
+    }
+
     public boolean isEnableCountPushDownForExternalTable() {
         return enableCountPushDownForExternalTable;
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/PipelineExecutionTaskBuilder.java b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/PipelineExecutionTaskBuilder.java
index d9503e3145a..03f6f923dc2 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/PipelineExecutionTaskBuilder.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/runtime/PipelineExecutionTaskBuilder.java
@@ -18,6 +18,7 @@
 package org.apache.doris.qe.runtime;
 
 import org.apache.doris.common.Pair;
+import org.apache.doris.common.profile.ExecutionProfile;
 import org.apache.doris.common.profile.SummaryProfile;
 import org.apache.doris.nereids.trees.plans.distribute.worker.BackendWorker;
 import org.apache.doris.nereids.trees.plans.distribute.worker.DistributedPlanWorker;
@@ -86,15 +87,16 @@ public class PipelineExecutionTaskBuilder {
                             backend,
                             backendServiceProxy,
                             serializeFragments,
-                            buildSingleFragmentPipelineTask(backend, fragmentParamsList)
+                            buildSingleFragmentPipelineTask(
+                                    coordinatorContext.executionProfile, backend, fragmentParamsList)
                     )
             );
         }
         return fragmentTasks;
     }
 
-    private Map<Integer, SingleFragmentPipelineTask> buildSingleFragmentPipelineTask(
-            Backend backend, TPipelineFragmentParamsList fragmentParamsList) {
+    static Map<Integer, SingleFragmentPipelineTask> buildSingleFragmentPipelineTask(
+            ExecutionProfile executionProfile, Backend backend, TPipelineFragmentParamsList fragmentParamsList) {
         Map<Integer, SingleFragmentPipelineTask> tasks = Maps.newLinkedHashMap();
         for (TPipelineFragmentParams fragmentParams : fragmentParamsList.getParamsList()) {
             int fragmentId = fragmentParams.getFragmentId();
@@ -103,6 +105,7 @@ public class PipelineExecutionTaskBuilder {
                     .map(TPipelineInstanceParams::getFragmentInstanceId)
                     .collect(Collectors.toSet());
             tasks.put(fragmentId, new SingleFragmentPipelineTask(backend, fragmentId, instanceIds));
+            executionProfile.addFragmentBackend(fragmentId, backend.getId());
         }
         return tasks;
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/system/Backend.java b/fe/fe-core/src/main/java/org/apache/doris/system/Backend.java
index 8e475bc07dc..adb4a57d448 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/system/Backend.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/system/Backend.java
@@ -145,6 +145,8 @@ public class Backend implements Writable {
     // The physical memory available for use by BE.
     @SerializedName("beMemory")
     private long beMemory = 0;
+    @SerializedName("supportsPaimonRustReader")
+    private volatile boolean supportsPaimonRustReader;
     // from config::pipeline_executor_size , default equal cpuCores
     @SerializedName("pipelineExecutorSize")
     private int pipelineExecutorSize = 1;
@@ -257,6 +259,10 @@ public class Backend implements Writable {
         return version;
     }
 
+    public boolean isPaimonRustReaderSupported() {
+        return supportsPaimonRustReader;
+    }
+
     public int getBePort() {
         return bePort;
     }
@@ -871,6 +877,11 @@ public class Backend implements Writable {
     public boolean handleHbResponse(BackendHbResponse hbResponse, boolean isReplay) {
         boolean isChanged = false;
         if (hbResponse.getStatus() == HbStatus.OK) {
+            // An absent capability also clears support after a downgrade or legacy heartbeat replay.
+            if (supportsPaimonRustReader != hbResponse.isPaimonRustReaderSupported()) {
+                isChanged = true;
+                supportsPaimonRustReader = hbResponse.isPaimonRustReaderSupported();
+            }
             if (!this.version.equals(hbResponse.getVersion())) {
                 isChanged = true;
                 this.version = hbResponse.getVersion();
diff --git a/fe/fe-core/src/main/java/org/apache/doris/system/BackendHbResponse.java b/fe/fe-core/src/main/java/org/apache/doris/system/BackendHbResponse.java
index 41fad9961a4..55fb0c9f456 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/system/BackendHbResponse.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/system/BackendHbResponse.java
@@ -51,6 +51,8 @@ public class BackendHbResponse extends HeartbeatResponse implements Writable {
     private boolean isShutDown = false;
     // The physical memory available for use by BE.
     private long beMemory = 0;
+    @SerializedName("supportsPaimonRustReader")
+    private boolean supportsPaimonRustReader;
 
     public BackendHbResponse() {
         super(HeartbeatResponse.Type.BACKEND);
@@ -152,6 +154,14 @@ public class BackendHbResponse extends HeartbeatResponse implements Writable {
         return beMemory;
     }
 
+    public boolean isPaimonRustReaderSupported() {
+        return supportsPaimonRustReader;
+    }
+
+    public void setPaimonRustReaderSupported(boolean supported) {
+        this.supportsPaimonRustReader = supported;
+    }
+
     @Override
     public String toString() {
         StringBuilder sb = new StringBuilder();
diff --git a/fe/fe-core/src/main/java/org/apache/doris/system/HeartbeatMgr.java b/fe/fe-core/src/main/java/org/apache/doris/system/HeartbeatMgr.java
index a8906e43df1..5e13e9191af 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/system/HeartbeatMgr.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/system/HeartbeatMgr.java
@@ -369,9 +369,12 @@ public class HeartbeatMgr extends MasterDaemon {
                         isShutDown = tBackendInfo.isIsShutdown();
                     }
                     long beMemory = tBackendInfo.isSetBeMem() ? tBackendInfo.getBeMem() : 0;
-                    return new BackendHbResponse(backendId, bePort, httpPort, brpcPort,
+                    BackendHbResponse response = new BackendHbResponse(backendId, bePort, httpPort, brpcPort,
                             System.currentTimeMillis(), beStartTime, version, nodeRole,
                             fragmentNum, lastFragmentUpdateTime, isShutDown, arrowFlightSqlPort, beMemory);
+                    response.setPaimonRustReaderSupported(tBackendInfo.isSetSupportsPaimonRustReader()
+                            && tBackendInfo.isSupportsPaimonRustReader());
+                    return response;
                 } else {
                     return new BackendHbResponse(backendId, backend.getHost(), backend.getLastUpdateMs(),
                             result.getStatus().getErrorMsgs().isEmpty()
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/profile/ProfileCompletionStateTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/profile/ProfileCompletionStateTest.java
new file mode 100644
index 00000000000..f263ff0b99f
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/profile/ProfileCompletionStateTest.java
@@ -0,0 +1,217 @@
+// 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.common.profile;
+
+import org.apache.doris.common.util.DebugUtil;
+import org.apache.doris.planner.PlanFragmentId;
+import org.apache.doris.thrift.TDetailedReportParams;
+import org.apache.doris.thrift.TNetworkAddress;
+import org.apache.doris.thrift.TQueryProfile;
+import org.apache.doris.thrift.TRuntimeProfileNode;
+import org.apache.doris.thrift.TRuntimeProfileTree;
+import org.apache.doris.thrift.TUniqueId;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.DataOutput;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+public class ProfileCompletionStateTest {
+    @Rule
+    public TemporaryFolder temporary = new TemporaryFolder();
+
+    private Profile profile() {
+        UUID id = UUID.randomUUID();
+        TUniqueId queryId = new TUniqueId(id.getMostSignificantBits(), id.getLeastSignificantBits());
+        Profile profile = new Profile();
+        profile.getSummaryProfile().getSummary().addInfoString(SummaryProfile.PROFILE_ID, DebugUtil.printId(queryId));
+        profile.addExecutionProfile(new ExecutionProfile(queryId, Collections.singletonList(0)));
+        return profile;
+    }
+
+    private void report(ExecutionProfile execution, String host, boolean done) {
+        TRuntimeProfileNode node = new TRuntimeProfileNode();
+        node.setName("PipelineProfile");
+        node.setNumChildren(0);
+        node.setCounters(new ArrayList<>());
+        node.setMetadata(0);
+        node.setIndent(false);
+        node.setInfoStrings(new HashMap<>());
+        node.setInfoStringsDisplayOrder(new ArrayList<>());
+        node.setChildCountersMap(new HashMap<>());
+        node.setTimestamp(0);
+        TDetailedReportParams params = new TDetailedReportParams();
+        params.setProfile(new TRuntimeProfileTree(Collections.singletonList(node)));
+        params.setIsFragmentLevel(false);
+        TQueryProfile report = new TQueryProfile();
+        report.setQueryId(execution.getQueryId());
+        // Multiple pipeline nodes from one BE must not count as multiple BE reports.
+        report.putToFragmentIdToProfile(0, Arrays.asList(params, params));
+        execution.updateProfile(report, new TNetworkAddress(host, 9050), done);
+    }
+
+    @Test
+    public void waitsForEveryBackendAndRejectsStaleReports() {
+        Profile profile = profile();
+        ExecutionProfile execution = profile.getExecutionProfiles().get(0);
+        execution.addFragmentBackend(new PlanFragmentId(0), 1L);
+        execution.addFragmentBackend(new PlanFragmentId(0), 2L);
+        Assert.assertEquals("RUNNING", profile.getProfileCompletionState());
+        profile.markQueryFinished();
+        Assert.assertEquals("COLLECTING", profile.getProfileCompletionState());
+        report(execution, "127.0.0.1", true);
+        Assert.assertEquals("COLLECTING", profile.getProfileCompletionState());
+        report(execution, "127.0.0.2", true);
+        Assert.assertEquals("COMPLETE", profile.getProfileCompletionState());
+        report(execution, "127.0.0.1", false);
+        Assert.assertEquals("COMPLETE", profile.getProfileCompletionState());
+        Assert.assertTrue(profile.getProfileByLevel().contains("Profile Completion State: COMPLETE"));
+    }
+
+    private static class PausingSummaryProfile extends SummaryProfile {
+        private final transient boolean beforeSerialization;
+        private final transient CountDownLatch storagePaused;
+        private final transient CountDownLatch resumeStorage;
+
+        private PausingSummaryProfile(boolean beforeSerialization, CountDownLatch storagePaused,
+                CountDownLatch resumeStorage) {
+            this.beforeSerialization = beforeSerialization;
+            this.storagePaused = storagePaused;
+            this.resumeStorage = resumeStorage;
+        }
+
+        @Override
+        public void write(DataOutput output) throws IOException {
+            if (!beforeSerialization) {
+                super.write(output);
+            }
+            storagePaused.countDown();
+            try {
+                if (!resumeStorage.await(10, TimeUnit.SECONDS)) {
+                    throw new IOException("Timed out waiting for concurrent profile rendering");
+                }
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+                throw new IOException(e);
+            }
+            if (beforeSerialization) {
+                super.write(output);
+            }
+        }
+    }
+
+    private static class FailingSummaryProfile extends SummaryProfile {
+        private transient boolean fail = true;
+
+        @Override
+        public void write(DataOutput output) throws IOException {
+            if (fail) {
+                fail = false;
+                throw new IOException("Injected storage failure");
+            }
+            super.write(output);
+        }
+    }
+
+    @Test
+    public void failedStorageDoesNotFreezeCompletionState() throws Exception {
+        Profile profile = profile();
+        SummaryProfile summary = new FailingSummaryProfile();
+        summary.getSummary().addInfoString(SummaryProfile.PROFILE_ID, profile.getId());
+        profile.setSummaryProfile(summary);
+        ExecutionProfile execution = profile.getExecutionProfiles().get(0);
+        execution.addFragmentBackend(new PlanFragmentId(0), 1L);
+        profile.markQueryFinished();
+        String directory = temporary.newFolder().getAbsolutePath();
+        profile.writeToStorage(directory);
+        Assert.assertFalse(profile.profileHasBeenStored());
+        Assert.assertEquals("COLLECTING", profile.getProfileCompletionState());
+        report(execution, "127.0.0.1", true);
+        profile.writeToStorage(directory);
+        Assert.assertTrue(profile.profileHasBeenStored());
+        Assert.assertEquals("COMPLETE", Profile.read(profile.getProfileStoragePath()).getProfileCompletionState());
+    }
+
+    @Test
+    public void renderingDuringStoragePreservesTerminalState() throws Exception {
+        for (boolean beforeSerialization : new boolean[] {true, false}) {
+            Profile profile = profile();
+            CountDownLatch storagePaused = new CountDownLatch(1);
+            CountDownLatch resumeStorage = new CountDownLatch(1);
+            SummaryProfile summary = new PausingSummaryProfile(beforeSerialization, storagePaused, resumeStorage);
+            summary.getSummary().addInfoString(SummaryProfile.PROFILE_ID, profile.getId());
+            profile.setSummaryProfile(summary);
+            profile.getExecutionProfiles().get(0).addFragmentBackend(new PlanFragmentId(0), 1L);
+            profile.markQueryFinished();
+            String directory = temporary.newFolder().getAbsolutePath();
+            ExecutorService writer = Executors.newSingleThreadExecutor();
+            try {
+                Future<?> stored = writer.submit(() -> profile.writeToStorage(directory));
+                Assert.assertTrue(storagePaused.await(10, TimeUnit.SECONDS));
+                // Render both before serialization and before path publication, while storage is paused.
+                profile.getProfileByLevel();
+                resumeStorage.countDown();
+                stored.get(10, TimeUnit.SECONDS);
+                Assert.assertTrue(profile.profileHasBeenStored());
+                Assert.assertEquals("INCOMPLETE", profile.getProfileCompletionState());
+                Profile restored = Profile.read(profile.getProfileStoragePath());
+                Assert.assertNotNull(restored);
+                Assert.assertEquals("INCOMPLETE", restored.getProfileCompletionState());
+            } finally {
+                resumeStorage.countDown();
+                writer.shutdownNow();
+                Assert.assertTrue(writer.awaitTermination(10, TimeUnit.SECONDS));
+            }
+        }
+    }
+
+    @Test
+    public void persistsTerminalStateBeforeReleasingReports() throws Exception {
+        for (boolean complete : new boolean[] {false, true}) {
+            Profile profile = profile();
+            ExecutionProfile execution = profile.getExecutionProfiles().get(0);
+            execution.addFragmentBackend(new PlanFragmentId(0), 1L);
+            profile.markQueryFinished();
+            if (complete) {
+                report(execution, "127.0.0.1", true);
+            }
+            profile.writeToStorage(temporary.newFolder().getAbsolutePath());
+            Assert.assertTrue(profile.profileHasBeenStored());
+            profile.releaseMemory();
+            String expected = complete ? "COMPLETE" : "INCOMPLETE";
+            Assert.assertEquals(expected, profile.getProfileCompletionState());
+            Profile restored = Profile.read(profile.getProfileStoragePath());
+            Assert.assertNotNull(restored);
+            Assert.assertEquals(expected, restored.getProfileCompletionState());
+            Assert.assertTrue(restored.getProfileByLevel().contains("Profile Completion State: " + expected));
+        }
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/common/profile/ProfileManagerTest.java b/fe/fe-core/src/test/java/org/apache/doris/common/profile/ProfileManagerTest.java
index 65778b8c512..f6d37c6d3b4 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/profile/ProfileManagerTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/profile/ProfileManagerTest.java
@@ -430,6 +430,35 @@ class ProfileManagerTest {
         Assertions.assertEquals(30 / 2, profiles.size());
     }
 
+    @Test
+    void testFailedSpillRemainsIncompleteAfterMemoryRelease() throws Exception {
+        UUID id = UUID.randomUUID();
+        TUniqueId queryId = new TUniqueId(id.getMostSignificantBits(), id.getLeastSignificantBits());
+        Profile profile = constructProfile(DebugUtil.printId(queryId));
+        ExecutionProfile execution = new ExecutionProfile(queryId, Lists.newArrayList(0));
+        execution.addFragmentBackend(0, 1L);
+        profile.addExecutionProfile(execution);
+        profile.markQueryFinished();
+        profile.setQueryFinishTimestamp(0);
+        profileManager.pushProfile(profile);
+        profileManager.addExecutionProfile(execution);
+        File invalidDirectory = new File(tempDir, "not_a_directory");
+        Assertions.assertTrue(invalidDirectory.createNewFile());
+        ProfileManager.PROFILE_STORAGE_PATH = invalidDirectory.getAbsolutePath();
+
+        profileManager.writeProfileToStorage();
+
+        Assertions.assertFalse(profile.profileHasBeenStored());
+        Assertions.assertTrue(profile.getExecutionProfiles().isEmpty());
+        Assertions.assertEquals("INCOMPLETE", profile.getProfileCompletionState());
+        Assertions.assertTrue(profile.getProfileByLevel().contains("Profile Completion State: INCOMPLETE"));
+        // A later successful spill cannot recreate the reports discarded after the failed write.
+        ProfileManager.PROFILE_STORAGE_PATH = tempDir.getAbsolutePath();
+        profileManager.writeProfileToStorage();
+        Assertions.assertTrue(profile.profileHasBeenStored());
+        Assertions.assertEquals("INCOMPLETE", Profile.read(profile.getProfileStoragePath()).getProfileCompletionState());
+    }
+
     @Test
     void testWriteProfileToStorage() throws InterruptedException {
         // Create some test profile files
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitorTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitorTest.java
new file mode 100644
index 00000000000..e54a1a2ff47
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/DorisToPaimonTypeVisitorTest.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.datasource.paimon;
+
+import org.apache.doris.catalog.ArrayType;
+import org.apache.doris.catalog.MapType;
+import org.apache.doris.catalog.ScalarType;
+import org.apache.doris.catalog.Type;
+import org.apache.doris.datasource.DorisTypeVisitor;
+
+import org.apache.paimon.casting.CastExecutor;
+import org.apache.paimon.casting.CastExecutors;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.types.CharType;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.TimestampType;
+import org.apache.paimon.types.VarCharType;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class DorisToPaimonTypeVisitorTest {
+    private DataType convert(Type type) {
+        return DorisTypeVisitor.visit(type, new DorisToPaimonTypeVisitor());
+    }
+
+    @Test
+    public void testCharacterBounds() {
+        Assert.assertEquals(new CharType(3), convert(ScalarType.createCharType(3)));
+        Assert.assertEquals(new VarCharType(3), convert(ScalarType.createVarcharType(3)));
+        Assert.assertEquals(new VarCharType(VarCharType.MAX_LENGTH), convert(Type.STRING));
+    }
+
+    @Test
+    public void testTimestampPrecision() {
+        for (int precision : new int[] {0, 3, 6}) {
+            Assert.assertEquals(new TimestampType(precision),
+                    convert(ScalarType.createDatetimeV2Type(precision)));
+        }
+        Assert.assertEquals(new TimestampType(0), convert(Type.DATETIME));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void testMappedHistoricalCasts() {
+        CastExecutor<BinaryString, ?> varcharCast = (CastExecutor<BinaryString, ?>) CastExecutors.resolve(
+                new VarCharType(10), convert(ScalarType.createVarcharType(3)));
+        Assert.assertNotNull(varcharCast);
+        Assert.assertEquals("abc", varcharCast.cast(BinaryString.fromString("abcdef")).toString());
+        CastExecutor<BinaryString, ?> timestampCast = (CastExecutor<BinaryString, ?>) CastExecutors.resolve(
+                new VarCharType(VarCharType.MAX_LENGTH), convert(ScalarType.createDatetimeV2Type(3)));
+        Assert.assertNotNull(timestampCast);
+        Assert.assertEquals("1970-01-01T00:00:00.042", timestampCast.cast(BinaryString.fromString("42")).toString());
+    }
+
+    @Test
+    public void testNestedBoundsAndPrecision() {
+        ArrayType array = new ArrayType(ScalarType.createVarcharType(3));
+        Assert.assertEquals(new org.apache.paimon.types.ArrayType(new VarCharType(3)), convert(array));
+        MapType map = new MapType(ScalarType.createCharType(2), ScalarType.createDatetimeV2Type(3));
+        Assert.assertEquals(new org.apache.paimon.types.MapType(new CharType(false, 2), new TimestampType(3)),
+                convert(map));
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustBackendCapabilityTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustBackendCapabilityTest.java
new file mode 100644
index 00000000000..24f0383cd87
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustBackendCapabilityTest.java
@@ -0,0 +1,118 @@
+// 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.paimon.source;
+
+import org.apache.doris.analysis.TupleDescriptor;
+import org.apache.doris.analysis.TupleId;
+import org.apache.doris.datasource.ExternalScanNode;
+import org.apache.doris.datasource.FederationBackendPolicy;
+import org.apache.doris.datasource.paimon.PaimonExternalTable;
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.planner.PlanNodeId;
+import org.apache.doris.planner.ScanContext;
+import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.system.Backend;
+import org.apache.doris.thrift.TFileRangeDesc;
+import org.apache.doris.thrift.TPaimonFileDesc;
+import org.apache.doris.thrift.TPaimonReaderType;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.IntType;
+import org.apache.paimon.utils.InstantiationUtil;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+
+public class PaimonRustBackendCapabilityTest {
+    @Test
+    public void testUnknownAndMixedBackendsUseJni() throws Exception {
+        Backend oldBackend = new Backend(1, "127.0.0.1", 9050);
+        Backend newBackend = capableBackend(2);
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, reader(Collections.emptyList(), true));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, reader(Collections.singletonList(oldBackend), true));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, reader(Arrays.asList(newBackend, oldBackend), true));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST, reader(Collections.singletonList(newBackend), true));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST, reader(Arrays.asList(newBackend, capableBackend(3)), true));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, reader(Collections.singletonList(newBackend), false));
+    }
+
+    private Backend capableBackend(long id) {
+        // Exercise the same persisted capability used when a follower plans the scan.
+        return GsonUtils.GSON.fromJson("{\"id\":" + id + ",\"supportsPaimonRustReader\":true}", Backend.class);
+    }
+
+    private TPaimonReaderType reader(List<Backend> backends, boolean enabled) throws Exception {
+        SessionVariable vars = new SessionVariable();
+        vars.setEnablePaimonRustReader(enabled);
+        vars.enableFileScannerV2 = true;
+        PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0), new TupleDescriptor(new TupleId(0)),
+                false, vars, ScanContext.EMPTY);
+        Field policyField = ExternalScanNode.class.getDeclaredField("backendPolicy");
+        policyField.setAccessible(true);
+        ((FederationBackendPolicy) policyField.get(node)).replaceBackendOrder(backends);
+
+        PaimonSource source = Mockito.mock(PaimonSource.class);
+        FileStoreTable table = Mockito.mock(FileStoreTable.class);
+        Mockito.when(table.schema()).thenReturn(new TableSchema(1,
+                Collections.singletonList(new DataField(0, "id", new IntType())), 0,
+                Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null));
+        PaimonExternalTable external = Mockito.mock(PaimonExternalTable.class);
+        Mockito.when(source.getExternalTable()).thenReturn(external);
+        Mockito.when(external.getDbName()).thenReturn("db");
+        Mockito.when(external.getName()).thenReturn("t");
+        Mockito.when(source.getTableLocation()).thenReturn("file:///warehouse/db/t");
+        node.setSource(source);
+        Field tableField = PaimonScanNode.class.getDeclaredField("processedTable");
+        tableField.setAccessible(true);
+        tableField.set(node, table);
+
+        DataFileMeta file = DataFileMeta.forAppend("data.parquet", 1024, 1, SimpleStats.EMPTY_STATS,
+                1, 1, 1, Collections.emptyList(), null, FileSource.APPEND,
+                Collections.emptyList(), null, null, Collections.emptyList());
+        DataSplit split = DataSplit.builder().rawConvertible(true).withPartition(BinaryRow.EMPTY_ROW)
+                .withBucket(0).withBucketPath("file:///warehouse/db/t/bucket-0")
+                .withDataFiles(Collections.singletonList(file)).build();
+        TFileRangeDesc range = new TFileRangeDesc();
+        Method setParams = PaimonScanNode.class.getDeclaredMethod("setPaimonParams",
+                TFileRangeDesc.class, PaimonSplit.class);
+        setParams.setAccessible(true);
+        setParams.invoke(node, range, new PaimonSplit(split));
+        TPaimonFileDesc fileDesc = range.getTableFormatParams().getPaimonParams();
+        if (fileDesc.getReaderType() == TPaimonReaderType.PAIMON_JNI) {
+            // Legacy BEs require the Java object encoding as well as the JNI reader enum.
+            DataSplit decoded = InstantiationUtil.deserializeObject(
+                    Base64.getUrlDecoder().decode(fileDesc.getPaimonSplit()), getClass().getClassLoader());
+            Assert.assertEquals("data.parquet", decoded.dataFiles().get(0).fileName());
+        }
+        return fileDesc.getReaderType();
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderCompatibilityTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderCompatibilityTest.java
new file mode 100644
index 00000000000..4e44aa67809
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderCompatibilityTest.java
@@ -0,0 +1,496 @@
+// 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.paimon.source;
+
+import org.apache.doris.analysis.TupleDescriptor;
+import org.apache.doris.analysis.TupleId;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.Decimal;
+import org.apache.paimon.data.GenericArray;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.data.Timestamp;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+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.source.DataSplit;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.function.Function;
+
+/** Real persisted files verify the Java result contract and the split metadata used by the gate. */
+public class PaimonRustReaderCompatibilityTest {
+    @Rule
+    public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+    @Test
+    public void testPersistedIntegerNarrowing() throws Exception {
+        FileStoreTable table = table(DataTypes.INT(), Collections.emptyMap());
+        commit(table, GenericRow.of(1, 383), GenericRow.of(2, -129), GenericRow.of(3, null));
+        assertRead(table, true, row -> row.isNullAt(1) ? null : row.getInt(1), Arrays.asList(383, -129, null));
+        table.schemaManager().commitChanges(Collections.singletonList(
+                SchemaChange.updateColumnType("v", DataTypes.TINYINT(), true)));
+        table = FileStoreTableFactory.create(table.fileIO(), table.location());
+        assertRead(table, false, row -> row.isNullAt(1) ? null : row.getByte(1),
+                Arrays.asList((byte) 127, (byte) 127, null));
+    }
+
+    @Test
+    public void testPersistedStringToTemporal() throws Exception {
+        for (boolean date : new boolean[] {true, false}) {
+            for (boolean nested : new boolean[] {false, true}) {
+                DataType sourceType = nested ? DataTypes.ROW(DataTypes.FIELD(2, "value", DataTypes.STRING()))
+                        : DataTypes.STRING();
+                DataType target = date ? DataTypes.DATE() : DataTypes.TIMESTAMP(3);
+                FileStoreTable table = table(Schema.newBuilder().column("id", DataTypes.INT())
+                        .column("v", sourceType), ImmutableMap.of("bucket", "-1"));
+                Object value = BinaryString.fromString("42");
+                commit(table, GenericRow.of(1, nested ? GenericRow.of(value) : value),
+                        GenericRow.of(2, nested ? GenericRow.of((Object) null) : null));
+                assertRead(table, true, row -> row.getInt(0), Arrays.asList(1, 2));
+                table.schemaManager().commitChanges(Collections.singletonList(SchemaChange.updateColumnType(
+                        nested ? new String[] {"v", "value"} : new String[] {"v"}, target, true)));
+                table = FileStoreTableFactory.create(table.fileIO(), table.location());
+                // Java accepts epoch-day/epoch-millisecond strings that Arrow's date parser rejects.
+                assertRead(table, false, row -> {
+                    InternalRow valueRow = nested ? row.getRow(1, 1) : row;
+                    int pos = nested ? 0 : 1;
+                    if (valueRow.isNullAt(pos)) {
+                        return null;
+                    }
+                    return date ? java.time.LocalDate.ofEpochDay(valueRow.getInt(pos)).toString()
+                            : valueRow.getTimestamp(pos, 3).toString();
+                }, Arrays.asList(date ? "1970-02-12" : "1970-01-01T00:00:00.042", null));
+            }
+        }
+    }
+
+    @Test
+    public void testPersistedIntegerToTimestamp() throws Exception {
+        for (boolean bigint : new boolean[] {false, true}) {
+            for (int precision : new int[] {0, 3, 6}) {
+                for (boolean nested : new boolean[] {false, true}) {
+                    DataType sourceType = bigint ? DataTypes.BIGINT() : DataTypes.INT();
+                    FileStoreTable table = table(Schema.newBuilder().column("id", DataTypes.INT())
+                            .column("v", nested ? DataTypes.ROW(DataTypes.FIELD(2, "value", sourceType)) : sourceType),
+                            ImmutableMap.of("bucket", "-1"));
+                    Object value = bigint ? (Object) 1700000000L : 1700000000;
+                    commit(table, GenericRow.of(1, nested ? GenericRow.of(value) : value),
+                            GenericRow.of(2, nested ? GenericRow.of((Object) null) : null));
+                    assertRead(table, true, row -> row.getInt(0), Arrays.asList(1, 2));
+                    table.schemaManager().commitChanges(Collections.singletonList(SchemaChange.updateColumnType(
+                            nested ? new String[] {"v", "value"} : new String[] {"v"},
+                            DataTypes.TIMESTAMP(precision), true)));
+                    table = FileStoreTableFactory.create(table.fileIO(), table.location());
+                    // Paimon numeric timestamps use seconds independently of the target precision.
+                    assertRead(table, false, row -> {
+                        InternalRow valueRow = nested ? row.getRow(1, 1) : row;
+                        int pos = nested ? 0 : 1;
+                        return valueRow.isNullAt(pos) ? null : valueRow.getTimestamp(pos, precision).toString();
+                    }, Arrays.asList("2023-11-14T22:13:20", null));
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testPersistedTimestampToDateBeforeEpoch() throws Exception {
+        // Java divides toward zero; Arrow takes the preceding calendar date before the epoch.
+        assertPersistedEvolution(DataTypes.TIMESTAMP(3), DataTypes.DATE(), Timestamp.fromEpochMillis(-1),
+                false, row -> row.getInt(1), 0);
+    }
+
+    @Test
+    public void testPersistedTimestampZoneChanges() throws Exception {
+        TimeZone previous = TimeZone.getDefault();
+        try {
+            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
+            // Paimon interprets NTZ/LTZ changes in the JVM zone, unlike an Arrow timezone cast.
+            assertPersistedEvolution(DataTypes.TIMESTAMP(3), DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3),
+                    Timestamp.fromEpochMillis(0), false, row -> row.getTimestamp(1, 3).getMillisecond(),
+                    -8L * 60 * 60 * 1000);
+            assertPersistedEvolution(DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3), DataTypes.TIMESTAMP(3),
+                    Timestamp.fromEpochMillis(0), false, row -> row.getTimestamp(1, 3).getMillisecond(),
+                    8L * 60 * 60 * 1000);
+        } finally {
+            TimeZone.setDefault(previous);
+        }
+    }
+
+    @Test
+    public void testPersistedTimeToTimestamp() throws Exception {
+        assertPersistedEvolution(DataTypes.TIME(3), DataTypes.TIMESTAMP(3), 1234, false,
+                row -> row.getTimestamp(1, 3).toString(), "1970-01-01T00:00:01.234");
+    }
+
+    @Test
+    public void testPersistedFormattedStrings() throws Exception {
+        assertPersistedEvolution(DataTypes.FLOAT(), DataTypes.STRING(), 0.0001f, false,
+                row -> row.getString(1).toString(), "1.0E-4");
+        assertPersistedEvolution(DataTypes.DOUBLE(), DataTypes.STRING(), 1.0e20, false,
+                row -> row.getString(1).toString(), "1.0E20");
+        assertPersistedEvolution(DataTypes.TIMESTAMP(3), DataTypes.STRING(), Timestamp.fromEpochMillis(1230),
+                false, row -> row.getString(1).toString(), "1970-01-01 00:00:01.230");
+        assertPersistedEvolution(DataTypes.TIME(3), DataTypes.STRING(), 1230, false,
+                row -> row.getString(1).toString(), "00:00:01.23");
+    }
+
+    @Test
+    public void testPersistedStringBounds() throws Exception {
+        assertPersistedEvolution(DataTypes.VARCHAR(10), DataTypes.VARCHAR(3), BinaryString.fromString("abcdef"),
+                false, row -> row.getString(1).toString(), "abc");
+        assertPersistedEvolution(DataTypes.CHAR(3), DataTypes.CHAR(10), BinaryString.fromString("x"),
+                false, row -> row.getString(1).toString(), "x         ");
+        assertPersistedEvolution(DataTypes.VARCHAR(3), DataTypes.VARCHAR(10), BinaryString.fromString("abc"),
+                true, row -> row.getString(1).toString(), "abc");
+    }
+
+    @Test
+    public void testPersistedRowToString() throws Exception {
+        DataType rowType = DataTypes.ROW(DataTypes.FIELD(2, "a", DataTypes.INT()),
+                DataTypes.FIELD(3, "b", DataTypes.STRING()));
+        assertPersistedEvolution(rowType, DataTypes.STRING(), GenericRow.of(11, BinaryString.fromString("x")),
+                false, row -> row.getString(1).toString(), "{11, x}");
+    }
+
+    @Test
+    public void testPersistedTimestampEvolutionIntoNanosecondRange() throws Exception {
+        List<String> expected = Arrays.asList("2300-01-01T00:00:00.123", "1600-01-01T00:00:00.123",
+                "2024-01-01T00:00:00.123", null);
+        for (int oldPrecision : new int[] {3, 6}) {
+            for (boolean localZone : new boolean[] {false, true}) {
+                DataType source = localZone ? DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(oldPrecision)
+                        : DataTypes.TIMESTAMP(oldPrecision);
+                FileStoreTable table = table(Schema.newBuilder().column("id", DataTypes.INT()).column("v", source),
+                        ImmutableMap.of("bucket", "-1"));
+                commit(table, GenericRow.of(1, Timestamp.fromLocalDateTime(LocalDateTime.parse(expected.get(0)))),
+                        GenericRow.of(2, Timestamp.fromLocalDateTime(LocalDateTime.parse(expected.get(1)))),
+                        GenericRow.of(3, Timestamp.fromLocalDateTime(LocalDateTime.parse(expected.get(2)))),
+                        GenericRow.of(4, null));
+                assertRead(table, true, row -> row.isNullAt(1) ? null
+                        : row.getTimestamp(1, oldPrecision).toString(), expected);
+                DataType target = localZone ? DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(9) : DataTypes.TIMESTAMP(9);
+                table.schemaManager().commitChanges(Collections.singletonList(
+                        SchemaChange.updateColumnType("v", target, true)));
+                table = FileStoreTableFactory.create(table.fileIO(), table.location());
+                // Java retains the full historical range; Arrow's nanosecond cast overflows these dates to NULL.
+                assertRead(table, false, row -> row.isNullAt(1) ? null : row.getTimestamp(1, 9).toString(), expected);
+            }
+        }
+    }
+
+    @Test
+    public void testPersistedSafeEvolutionControls() throws Exception {
+        assertPersistedEvolution(DataTypes.INT(), DataTypes.BIGINT(), 123, true, row -> row.getLong(1), 123L);
+        assertPersistedEvolution(DataTypes.INT(), DataTypes.DECIMAL(12, 2), 123, true,
+                row -> row.getDecimal(1, 12, 2).toBigDecimal().toPlainString(), "123.00");
+        assertPersistedEvolution(DataTypes.DECIMAL(10, 3), DataTypes.DECIMAL(10, 2),
+                Decimal.fromUnscaledLong(1235, 10, 3), true,
+                row -> row.getDecimal(1, 10, 2).toBigDecimal().toPlainString(), "1.24");
+        assertPersistedEvolution(DataTypes.TIMESTAMP(3), DataTypes.TIMESTAMP(6), Timestamp.fromEpochMillis(1234),
+                true, row -> row.getTimestamp(1, 6).toString(), "1970-01-01T00:00:01.234");
+    }
+
+    private <T> void assertPersistedEvolution(DataType sourceType, DataType targetType, Object value,
+            boolean rustExpected, Function<InternalRow, T> extract, T expected) throws Exception {
+        FileStoreTable table = table(Schema.newBuilder().column("id", DataTypes.INT()).column("v", sourceType),
+                ImmutableMap.of("bucket", "-1"));
+        commit(table, GenericRow.of(1, value), GenericRow.of(2, null));
+        assertRead(table, true, row -> row.getInt(0), Arrays.asList(1, 2));
+        table.schemaManager().commitChanges(Collections.singletonList(
+                SchemaChange.updateColumnType("v", targetType, true)));
+        table = FileStoreTableFactory.create(table.fileIO(), table.location());
+        assertRead(table, rustExpected, row -> row.isNullAt(1) ? null : extract.apply(row),
+                Arrays.asList(expected, null));
+    }
+
+    @Test
+    public void testPersistedFloatingToDecimalRounding() throws Exception {
+        FileStoreTable table = table(Schema.newBuilder().column("id", DataTypes.INT())
+                .column("v", DataTypes.DOUBLE()), ImmutableMap.of("bucket", "-1"));
+        commit(table, GenericRow.of(1, 1.005), GenericRow.of(2, -1.005), GenericRow.of(3, 1.25),
+                GenericRow.of(4, null));
+        assertRead(table, true, row -> row.getInt(0), Arrays.asList(1, 2, 3, 4));
+        table.schemaManager().commitChanges(Collections.singletonList(
+                SchemaChange.updateColumnType("v", DataTypes.DECIMAL(3, 2), true)));
+        table = FileStoreTableFactory.create(table.fileIO(), table.location());
+        assertRead(table, false, row -> row.isNullAt(1) ? null
+                : row.getDecimal(1, 3, 2).toBigDecimal().toPlainString(), Arrays.asList("1.01", "-1.01", "1.25", null));
+    }
+
+    @Test
+    public void testPersistedUnusedAggregateDefault() throws Exception {
+        for (String engine : Arrays.asList("aggregation", "partial-update")) {
+            for (String function : Arrays.asList("collect", "sum")) {
+                Map<String, String> options = new HashMap<>();
+                options.put("merge-engine", engine);
+                options.put("fields.default-aggregate-function", function);
+                options.put("fields.v.aggregate-function", "max");
+                Schema.Builder schema = Schema.newBuilder().column("id", DataTypes.INT().notNull())
+                        .column("v", DataTypes.INT()).primaryKey("id");
+                boolean partial = "partial-update".equals(engine);
+                if (partial) {
+                    schema.column("seq", DataTypes.INT());
+                    options.put("fields.seq.sequence-group", "v");
+                }
+                FileStoreTable table = table(schema, options);
+                commit(table, partial ? GenericRow.of(1, 7, 1) : GenericRow.of(1, 7));
+                // An unused function override cannot establish that historical files contain unique keys.
+                assertRead(table, false, row -> row.getInt(1), Collections.singletonList(7));
+            }
+        }
+    }
+
+    @Test
+    public void testPersistedNonIntegerNarrowing() throws Exception {
+        for (DataType type : Arrays.asList(DataTypes.DOUBLE(), DataTypes.DECIMAL(10, 2))) {
+            boolean floating = type.equals(DataTypes.DOUBLE());
+            FileStoreTable table = table(type, Collections.emptyMap());
+            commit(table, GenericRow.of(1, floating ? 383.0 : Decimal.fromUnscaledLong(38300, 10, 2)),
+                    GenericRow.of(2, floating ? -129.0 : Decimal.fromUnscaledLong(-12900, 10, 2)),
+                    GenericRow.of(3, floating ? 12.0 : Decimal.fromUnscaledLong(1200, 10, 2)),
+                    GenericRow.of(4, null));
+            assertRead(table, true, row -> row.getInt(0), Arrays.asList(1, 2, 3, 4));
+            table.schemaManager().commitChanges(Collections.singletonList(
+                    SchemaChange.updateColumnType("v", DataTypes.TINYINT(), true)));
+            table = FileStoreTableFactory.create(table.fileIO(), table.location());
+            assertRead(table, false, row -> row.isNullAt(1) ? null : row.getByte(1),
+                    Arrays.asList((byte) 127, (byte) 127, (byte) 12, null));
+        }
+    }
+
+    @Test
+    public void testPersistedPartialUpdateDefaultAggregate() throws Exception {
+        for (boolean decimal : new boolean[] {false, true}) {
+            DataType type = decimal ? DataTypes.DECIMAL(2, 0) : DataTypes.TINYINT();
+            for (boolean override : new boolean[] {false, true}) {
+                Map<String, String> options = new HashMap<>();
+                options.put("merge-engine", "partial-update");
+                options.put("fields.seq.sequence-group", "v");
+                options.put("fields.default-aggregate-function", "sum");
+                if (override) {
+                    options.put("fields.v.aggregate-function", "max");
+                }
+                FileStoreTable table = table(Schema.newBuilder().column("id", DataTypes.INT().notNull())
+                        .column("v", type).column("seq", DataTypes.INT()).primaryKey("id"), options);
+                commit(table, GenericRow.of(1, decimal ? Decimal.fromUnscaledLong(99, 2, 0) : (byte) 127, 1));
+                // Single-file partial updates must also fall back: old writers retained repeated keys.
+                assertRead(table, false, row -> decimal
+                        ? row.getDecimal(1, 2, 0).toBigDecimal().toPlainString()
+                        : Byte.toString(row.getByte(1)), Collections.singletonList(decimal ? "99" : "127"));
+                commit(table, GenericRow.of(1, decimal ? Decimal.fromUnscaledLong(1, 2, 0) : (byte) 1, 2));
+                if (decimal) {
+                    commit(table, GenericRow.of(1, Decimal.fromUnscaledLong(-1, 2, 0), 3));
+                }
+                assertRead(table, false, row -> decimal
+                        ? row.getDecimal(1, 2, 0).toBigDecimal().toPlainString()
+                        : Byte.toString(row.getByte(1)), Collections.singletonList(
+                                decimal ? "99" : override ? "127" : "-128"));
+            }
+        }
+    }
+
+    @Test
+    public void testPersistedInsertOnlyOverlappingRunsFallback() throws Exception {
+        FileStoreTable table = table(DataTypes.STRING(), ImmutableMap.of("read.batch-size", "1"));
+        for (int run = 0; run < 3; run++) {
+            List<GenericRow> rows = new ArrayList<>();
+            String payload = run + String.join("", Collections.nCopies(4096, "x"));
+            for (int key = 0; key < 1025; key++) {
+                rows.add(GenericRow.of(key, BinaryString.fromString(payload)));
+            }
+            commit(table, rows.toArray(new GenericRow[0]));
+        }
+        List<Split> splits = table.newReadBuilder().newScan().plan().splits();
+        Assert.assertEquals("Overlapping runs must be read in one logical split", 1, splits.size());
+        DataSplit split = (DataSplit) splits.get(0);
+        Assert.assertEquals(3, split.dataFiles().size());
+        split.dataFiles().forEach(file -> {
+            Assert.assertEquals(1025L, file.rowCount());
+            Assert.assertEquals(Long.valueOf(0), file.deleteRowCount().get());
+        });
+        assertRead(table, false, row -> {
+            String payload = row.getString(1).toString();
+            Assert.assertEquals(4097, payload.length());
+            return payload.substring(0, 1);
+        }, Collections.nCopies(1025, "2"));
+    }
+
+    @Test
+    public void testPersistedAppendOnlyMultiFileControl() throws Exception {
+        FileStoreTable table = table(Schema.newBuilder().column("id", DataTypes.INT()).column("v", DataTypes.INT()),
+                ImmutableMap.of("bucket", "-1"));
+        commit(table, GenericRow.of(1, 11));
+        commit(table, GenericRow.of(2, 22));
+        Assert.assertTrue(table.newReadBuilder().newScan().plan().splits().stream()
+                .anyMatch(split -> ((DataSplit) split).dataFiles().size() > 1));
+        assertRead(table, true, row -> row.getInt(1), Arrays.asList(11, 22));
+    }
+
+    @Test
+    public void testPersistedIntegerSumAndProductOverflow() throws Exception {
+        DataType[] types = {DataTypes.TINYINT(), DataTypes.SMALLINT(), DataTypes.INT(), DataTypes.BIGINT()};
+        Object[] maximums = {(byte) 127, (short) 32767, Integer.MAX_VALUE, Long.MAX_VALUE};
+        Object[] ones = {(byte) 1, (short) 1, 1, 1L};
+        Object[] halves = {(byte) 64, (short) 16384, 1073741824, 4611686018427387904L};
+        Object[] twos = {(byte) 2, (short) 2, 2, 2L};
+        for (String function : Arrays.asList("sum", "product")) {
+            for (int i = 0; i < types.length; i++) {
+                FileStoreTable table = table(types[i], aggregate(function));
+                commit(table, GenericRow.of(1, "sum".equals(function) ? maximums[i] : halves[i]));
+                commit(table, GenericRow.of(1, "sum".equals(function) ? ones[i] : twos[i]));
+                InternalRow.FieldGetter getter = InternalRow.createFieldGetter(types[i], 1);
+                Object minimum = i == 0 ? (byte) -128 : i == 1 ? (short) -32768
+                        : i == 2 ? Integer.MIN_VALUE : Long.MIN_VALUE;
+                // Compare decimal strings to avoid Java's conditional numeric promotion.
+                assertRead(table, false, row -> getter.getFieldOrNull(row).toString(),
+                        Collections.singletonList(minimum.toString()));
+            }
+        }
+    }
+
+    @Test
+    public void testPersistedCompactDecimalIntermediateOverflow() throws Exception {
+        FileStoreTable table = table(DataTypes.DECIMAL(2, 0), aggregate("sum"));
+        for (long value : new long[] {99, 1, -1}) {
+            commit(table, GenericRow.of(1, Decimal.fromUnscaledLong(value, 2, 0)));
+        }
+        assertRead(table, false, row -> row.getDecimal(1, 2, 0).toBigDecimal().toPlainString(),
+                Collections.singletonList("99"));
+    }
+
+    @Test
+    public void testPersistedCollect() throws Exception {
+        FileStoreTable table = table(DataTypes.ARRAY(DataTypes.INT()), aggregate("collect"));
+        commit(table, GenericRow.of(1, new GenericArray(new Integer[] {11})));
+        commit(table, GenericRow.of(1, new GenericArray(new Integer[] {22})));
+        assertRead(table, false, row -> {
+            List<Integer> values = Arrays.asList(row.getArray(1).getInt(0), row.getArray(1).getInt(1));
+            Collections.sort(values);
+            return values;
+        }, Collections.singletonList(Arrays.asList(11, 22)));
+    }
+
+    @Test
+    public void testPersistedAggregateInsertsAndRetractsFallback() throws Exception {
+        for (RowKind kind : Arrays.asList(RowKind.DELETE, RowKind.UPDATE_BEFORE)) {
+            FileStoreTable table = table(DataTypes.DOUBLE(), aggregate("sum"));
+            commit(table, GenericRow.of(1, 10.0));
+            assertRead(table, false, row -> row.getDouble(1), Collections.singletonList(10.0));
+            commit(table, GenericRow.of(1, 3.0));
+            assertRead(table, false, row -> row.getDouble(1), Collections.singletonList(13.0));
+            commit(table, GenericRow.ofKind(kind, 1, 3.0));
+            assertRead(table, false, row -> row.getDouble(1), Collections.singletonList(10.0));
+        }
+    }
+
+    @Test
+    public void testPersistedZeroAndSparseOutputMerge() throws Exception {
+        for (boolean survivor : new boolean[] {false, true}) {
+            FileStoreTable table = table(DataTypes.INT(), Collections.emptyMap());
+            List<GenericRow> inserts = new ArrayList<>();
+            List<GenericRow> deletes = new ArrayList<>();
+            for (int i = 0; i < 2048; i++) {
+                inserts.add(GenericRow.of(i, i));
+                deletes.add(GenericRow.ofKind(RowKind.DELETE, i, i));
+            }
+            if (survivor) {
+                inserts.add(GenericRow.of(2048, 2048));
+            }
+            commit(table, inserts.toArray(new GenericRow[0]));
+            commit(table, deletes.toArray(new GenericRow[0]));
+            assertRead(table, false, row -> row.getInt(1), survivor
+                    ? Collections.singletonList(2048) : Collections.emptyList());
+        }
+    }
+
+    private static Map<String, String> aggregate(String function) {
+        return ImmutableMap.of("merge-engine", "aggregation", "fields.v.aggregate-function", function);
+    }
+
+    private FileStoreTable table(DataType type, Map<String, String> options) throws Exception {
+        return table(Schema.newBuilder().column("id", DataTypes.INT().notNull()).column("v", type)
+                .primaryKey("id"), options);
+    }
+
+    private FileStoreTable table(Schema.Builder schema, Map<String, String> options) throws Exception {
+        Path path = new Path(temporaryFolder.newFolder().toURI());
+        LocalFileIO fileIO = LocalFileIO.create();
+        schema.option("bucket", "1").option("write-only", "true")
+                .option("file.format", "parquet").option("deletion-vectors.enabled", "false")
+                .option("read.batch-size", "16").option("scan.manifest.parallelism", "1");
+        options.forEach(schema::option);
+        new SchemaManager(fileIO, path).createTable(schema.build());
+        return FileStoreTableFactory.create(fileIO, path);
+    }
+
+    private static void commit(FileStoreTable table, GenericRow... rows) throws Exception {
+        // Separate writers force persisted, uncompacted inputs instead of a write-time reduction.
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite(); BatchTableCommit commit = builder.newCommit()) {
+            for (GenericRow row : rows) {
+                write.write(row);
+            }
+            commit.commit(write.prepareCommit());
+        }
+    }
+
+    private static <T> void assertRead(FileStoreTable table, boolean rustExpected,
+            Function<InternalRow, T> extract, List<T> expected) throws Exception {
+        ReadBuilder builder = table.newReadBuilder();
+        List<Split> splits = builder.newScan().plan().splits();
+        Assert.assertFalse("Persisted files must reach scan planning", splits.isEmpty());
+        PaimonRustReaderCapabilities capabilities = new PaimonRustReaderCapabilities(table,
+                new TupleDescriptor(new TupleId(0)));
+        List<T> actual = new ArrayList<>();
+        for (Split split : splits) {
+            Assert.assertEquals("reader eligibility for persisted input", rustExpected,
+                    capabilities.canRead((DataSplit) split));
+            try (RecordReader<InternalRow> reader = builder.newRead().createReader(split)) {
+                reader.forEachRemaining(row -> actual.add(extract.apply(row)));
+            }
+        }
+        Assert.assertEquals(expected, actual);
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderEvolutionTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderEvolutionTest.java
new file mode 100644
index 00000000000..e3ee311e38c
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderEvolutionTest.java
@@ -0,0 +1,184 @@
+// 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.paimon.source;
+
+import org.apache.doris.analysis.TupleDescriptor;
+import org.apache.doris.analysis.TupleId;
+
+import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.manifest.FileSource;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.stats.SimpleStats;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.source.DataSplit;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Optional;
+
+public class PaimonRustReaderEvolutionTest {
+    @Test
+    public void testPrimaryKeyMergeEnginesWithOneInsertOnlyFile() {
+        for (String engine : Arrays.asList("deduplicate", "first-row", "partial-update", "aggregation")) {
+            FileStoreTable table = Mockito.mock(FileStoreTable.class);
+            TableSchema schema = new TableSchema(2, Arrays.asList(new DataField(0, "id", DataTypes.INT()),
+                    new DataField(1, "v", DataTypes.DOUBLE())), 1, Collections.emptyList(),
+                    Collections.singletonList("id"), Collections.singletonMap("merge-engine", engine), null);
+            Mockito.when(table.schema()).thenReturn(schema);
+            DataFileMeta file = Mockito.mock(DataFileMeta.class);
+            Mockito.when(file.schemaId()).thenReturn(2L);
+            Mockito.when(file.deleteRowCount()).thenReturn(Optional.of(0L));
+            DataSplit split = DataSplit.builder().withPartition(BinaryRow.EMPTY_ROW).withBucket(0)
+                    .withBucketPath("file:///warehouse/table/bucket-0")
+                    .withDataFiles(Collections.singletonList(file)).build();
+            // Older writers can keep repeated inserts inside one aggregate/partial-update file.
+            Assert.assertEquals(engine, "deduplicate".equals(engine) || "first-row".equals(engine),
+                    new PaimonRustReaderCapabilities(table, new TupleDescriptor(new TupleId(0))).canRead(split));
+        }
+    }
+
+    @Test
+    public void testTimestampEvolutionIntoNanosecondRangeFallback() {
+        for (int oldPrecision : new int[] {0, 3, 6}) {
+            for (int newPrecision : new int[] {7, 8, 9}) {
+                assertNestedEvolution(DataTypes.TIMESTAMP(oldPrecision), DataTypes.TIMESTAMP(newPrecision), false);
+                assertNestedEvolution(DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(oldPrecision),
+                        DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(newPrecision), false);
+            }
+        }
+        assertNestedEvolution(DataTypes.TIMESTAMP(7), DataTypes.TIMESTAMP(9), true);
+    }
+
+    @Test
+    public void testTimestampToDateFallback() {
+        assertNestedEvolution(DataTypes.TIMESTAMP(3), DataTypes.DATE(), false);
+        assertNestedEvolution(DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3), DataTypes.DATE(), false);
+    }
+
+    @Test
+    public void testTimestampZoneChangeFallback() {
+        assertNestedEvolution(DataTypes.TIMESTAMP(3), DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3), false);
+        assertNestedEvolution(DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3), DataTypes.TIMESTAMP(3), false);
+    }
+
+    @Test
+    public void testTimeToTimestampFallback() {
+        assertNestedEvolution(DataTypes.TIME(3), DataTypes.TIMESTAMP(3), false);
+        assertNestedEvolution(DataTypes.TIME(3), DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3), false);
+    }
+
+    @Test
+    public void testFormattedStringFallback() {
+        for (DataType source : Arrays.asList(DataTypes.FLOAT(), DataTypes.DOUBLE(), DataTypes.TIME(3),
+                DataTypes.TIMESTAMP(3), DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3))) {
+            for (DataType target : Arrays.asList(DataTypes.STRING(), DataTypes.VARCHAR(30), DataTypes.CHAR(30))) {
+                assertNestedEvolution(source, target, false);
+            }
+        }
+    }
+
+    @Test
+    public void testConstructedStringFallback() {
+        for (DataType source : Arrays.asList(DataTypes.ROW(DataTypes.FIELD(2, "a", DataTypes.INT())),
+                DataTypes.ARRAY(DataTypes.INT()), DataTypes.MAP(DataTypes.INT(), DataTypes.STRING()))) {
+            assertNestedEvolution(source, DataTypes.STRING(), false);
+        }
+    }
+
+    @Test
+    public void testUnprovenScalarCastsFallback() {
+        assertNestedEvolution(DataTypes.STRING(), DataTypes.TIME(3), false);
+        assertNestedEvolution(DataTypes.DATE(), DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3), false);
+        assertNestedEvolution(DataTypes.TIMESTAMP(6), DataTypes.TIMESTAMP(3), false);
+        assertNestedEvolution(DataTypes.VARCHAR(10), DataTypes.VARCHAR(3), false);
+        assertNestedEvolution(DataTypes.CHAR(3), DataTypes.CHAR(10), false);
+    }
+
+    @Test
+    public void testSafeEvolutionControls() {
+        DataType[][] pairs = {
+                {DataTypes.TINYINT(), DataTypes.SMALLINT()},
+                {DataTypes.INT(), DataTypes.BIGINT()},
+                {DataTypes.INT(), DataTypes.DOUBLE()},
+                {DataTypes.INT(), DataTypes.DECIMAL(12, 2)},
+                {DataTypes.FLOAT(), DataTypes.DOUBLE()},
+                {DataTypes.DECIMAL(10, 3), DataTypes.DECIMAL(10, 2)},
+                {DataTypes.VARCHAR(10), DataTypes.VARCHAR(30)},
+                {DataTypes.CHAR(3), DataTypes.VARCHAR(10)},
+                {DataTypes.TIMESTAMP(3), DataTypes.TIMESTAMP(6)},
+                {DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3), DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(6)},
+                {DataTypes.DATE(), DataTypes.DATE()}
+        };
+        for (DataType[] pair : pairs) {
+            assertNestedEvolution(pair[0], pair[1], true);
+        }
+    }
+
+    @Test
+    public void testNestedFieldsMatchById() {
+        DataType oldType = DataTypes.ROW(DataTypes.FIELD(2, "removed", DataTypes.TIMESTAMP(3)),
+                DataTypes.FIELD(3, "old_name", DataTypes.INT()));
+        DataType newType = DataTypes.ROW(DataTypes.FIELD(3, "renamed", DataTypes.BIGINT()),
+                DataTypes.FIELD(4, "added", DataTypes.STRING()));
+        assertNestedEvolution(oldType, newType, true);
+    }
+
+    private void assertNestedEvolution(DataType source, DataType target, boolean expected) {
+        DataType[][] shapes = {
+                {source, target},
+                {DataTypes.ROW(new DataField(10, "old_name", source)),
+                        DataTypes.ROW(new DataField(10, "renamed", target))},
+                {DataTypes.ARRAY(source), DataTypes.ARRAY(target)},
+                {DataTypes.MAP(source, DataTypes.INT()), DataTypes.MAP(target, DataTypes.INT())},
+                {DataTypes.MAP(DataTypes.INT(), source), DataTypes.MAP(DataTypes.INT(), target)}
+        };
+        for (DataType[] shape : shapes) {
+            Assert.assertTrue("Unchanged history must remain eligible: " + shape[0], canRead(shape[0], shape[0]));
+            Assert.assertEquals(Arrays.toString(shape), expected, canRead(shape[0], shape[1]));
+        }
+    }
+
+    private boolean canRead(DataType source, DataType target) {
+        FileStoreTable table = Mockito.mock(FileStoreTable.class);
+        SchemaManager schemas = Mockito.mock(SchemaManager.class);
+        Mockito.when(table.schema()).thenReturn(schema(2, target));
+        Mockito.when(table.schemaManager()).thenReturn(schemas);
+        Mockito.when(schemas.schema(1)).thenReturn(schema(1, source));
+        DataFileMeta file = DataFileMeta.forAppend("historical.parquet", 100L, 1L, SimpleStats.EMPTY_STATS,
+                0L, 0L, 1L, Collections.emptyList(), null, FileSource.APPEND,
+                Collections.emptyList(), null, null, Collections.emptyList());
+        DataSplit split = DataSplit.builder().withPartition(BinaryRow.EMPTY_ROW).withBucket(0)
+                .withBucketPath("file:///warehouse/table/bucket-0")
+                .withDataFiles(Collections.singletonList(file)).build();
+        return new PaimonRustReaderCapabilities(table, new TupleDescriptor(new TupleId(0))).canRead(split);
+    }
+
+    private TableSchema schema(long id, DataType valueType) {
+        return new TableSchema(id, Arrays.asList(new DataField(0, "id", DataTypes.INT()),
+                new DataField(1, "v", valueType)), 10, Collections.emptyList(), Collections.emptyList(),
+                Collections.emptyMap(), null);
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderLegacySchemaTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderLegacySchemaTest.java
new file mode 100644
index 00000000000..1ed35e94007
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonRustReaderLegacySchemaTest.java
@@ -0,0 +1,101 @@
+// 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.paimon.source;
+
+import org.apache.doris.analysis.TupleDescriptor;
+import org.apache.doris.analysis.TupleId;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.schema.Schema;
+import org.apache.paimon.schema.SchemaChange;
+import org.apache.paimon.schema.SchemaManager;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.FileStoreTableFactory;
+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.source.DataSplit;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.types.DataTypes;
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class PaimonRustReaderLegacySchemaTest {
+    @Rule
+    public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+    @Test
+    public void testPersistedLegacySchemaFallsBackBeforeAndAfterEvolution() throws Exception {
+        java.nio.file.Path directory = temporaryFolder.newFolder().toPath();
+        Path location = new Path(directory.toUri());
+        LocalFileIO fileIO = LocalFileIO.create();
+        new SchemaManager(fileIO, location).createTable(Schema.newBuilder()
+                .column("id", DataTypes.INT()).option("bucket", "-1")
+                .option("file.format", "parquet").build());
+        FileStoreTable table = FileStoreTableFactory.create(fileIO, location);
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite(); BatchTableCommit commit = builder.newCommit()) {
+            write.write(GenericRow.of(7));
+            commit.commit(write.prepareCommit());
+        }
+        assertReadable(table, true);
+        // Older producers omitted version. Java accepts that schema, but Rust requires it
+        // even when reopening a historical schema for otherwise compatible field evolution.
+        java.nio.file.Path schemaFile = directory.resolve("schema/schema-0");
+        // Fixture I/O must also work with the FE test compilation target of Java 8.
+        String json = new String(Files.readAllBytes(schemaFile), StandardCharsets.UTF_8);
+        String legacy = json.replaceFirst("\"version\"\\s*:\\s*\\d+\\s*,", "");
+        Assert.assertNotEquals(json, legacy);
+        Files.write(schemaFile, legacy.getBytes(StandardCharsets.UTF_8));
+        table = FileStoreTableFactory.create(fileIO, location);
+        Assert.assertEquals(1, table.schema().version());
+        assertReadable(table, false);
+        table.schemaManager().commitChanges(Collections.singletonList(
+                SchemaChange.addColumn("extra", DataTypes.STRING())));
+        table = FileStoreTableFactory.create(fileIO, location);
+        Assert.assertTrue(table.schema().version() > 0);
+        assertReadable(table, false);
+    }
+
+    private static void assertReadable(FileStoreTable table, boolean rustExpected) throws Exception {
+        List<Split> splits = table.newScan().plan().splits();
+        Assert.assertFalse(splits.isEmpty());
+        PaimonRustReaderCapabilities capabilities = new PaimonRustReaderCapabilities(table,
+                new TupleDescriptor(new TupleId(0)));
+        List<Integer> actual = new ArrayList<>();
+        for (Split split : splits) {
+            Assert.assertEquals(rustExpected, capabilities.canRead((DataSplit) split));
+            try (RecordReader<InternalRow> reader = table.newRead().createReader(split)) {
+                reader.forEachRemaining(row -> actual.add(row.getInt(0)));
+            }
+        }
+        Assert.assertEquals(Collections.singletonList(7), actual);
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonScanNodeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonScanNodeTest.java
index d744973a566..2508f3c7bbe 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonScanNodeTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/paimon/source/PaimonScanNodeTest.java
@@ -35,6 +35,7 @@ import org.apache.doris.common.ExceptionChecker;
 import org.apache.doris.common.UserException;
 import org.apache.doris.datasource.CatalogIf;
 import org.apache.doris.datasource.CatalogProperty;
+import org.apache.doris.datasource.FederationBackendPolicy;
 import org.apache.doris.datasource.FileQueryScanNode;
 import org.apache.doris.datasource.FileSplitter;
 import org.apache.doris.datasource.hive.HMSExternalTable;
@@ -54,10 +55,12 @@ import org.apache.doris.datasource.paimon.PaimonUtils;
 import org.apache.doris.datasource.property.metastore.MetastoreProperties;
 import org.apache.doris.datasource.property.metastore.PaimonJdbcMetaStoreProperties;
 import org.apache.doris.nereids.StatementContext;
+import org.apache.doris.persist.gson.GsonUtils;
 import org.apache.doris.planner.PlanNodeId;
 import org.apache.doris.planner.ScanContext;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.system.Backend;
 import org.apache.doris.thrift.TFileFormatType;
 import org.apache.doris.thrift.TFileRangeDesc;
 import org.apache.doris.thrift.TFileScanRangeParams;
@@ -65,21 +68,26 @@ import org.apache.doris.thrift.TPaimonReaderType;
 import org.apache.doris.thrift.TPushAggOp;
 
 import com.google.common.collect.ImmutableMap;
+import mockit.MockUp;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.Snapshot;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.io.DataInputDeserializer;
+import org.apache.paimon.io.DataOutputSerializer;
 import org.apache.paimon.manifest.FileSource;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.rest.RESTTokenFileIO;
 import org.apache.paimon.schema.TableSchema;
 import org.apache.paimon.stats.SimpleStats;
 import org.apache.paimon.table.AppendOnlyFileStoreTable;
 import org.apache.paimon.table.BucketMode;
 import org.apache.paimon.table.CatalogEnvironment;
 import org.apache.paimon.table.DataTable;
+import org.apache.paimon.table.FallbackReadFileStoreTable;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.table.Table;
 import org.apache.paimon.table.source.DataSplit;
@@ -91,7 +99,9 @@ import org.apache.paimon.table.source.snapshot.SnapshotReader;
 import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.IntType;
+import org.apache.paimon.types.LocalZonedTimestampType;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.types.TimestampType;
 import org.apache.paimon.utils.InstantiationUtil;
 import org.junit.After;
 import org.junit.Assert;
@@ -110,6 +120,7 @@ import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Base64;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -117,6 +128,7 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.OptionalLong;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
 
 @RunWith(MockitoJUnitRunner.class)
 public class PaimonScanNodeTest {
@@ -134,6 +146,14 @@ public class PaimonScanNodeTest {
         // Statement-scoped scan task reuse is on by default; the mock's field would otherwise
         // read false and bypass the cache under test.
         sv.enableExternalScanTaskReuse = true;
+        // These routing fixtures bypass scan initialization and use an upgraded eligible BE.
+        Backend backend = GsonUtils.GSON.fromJson("{\"supportsPaimonRustReader\":true}", Backend.class);
+        new MockUp<FederationBackendPolicy>() {
+            @mockit.Mock
+            public Collection<Backend> getBackends() {
+                return Collections.singletonList(backend);
+            }
+        };
     }
 
     @After
@@ -474,6 +494,43 @@ public class PaimonScanNodeTest {
         Mockito.verify(dataSplit, Mockito.never()).mergedRowCount();
     }
 
+    @Test
+    public void testRustEnabledKeepsNativeAggregateSplits() throws Exception {
+        SessionVariable session = new SessionVariable();
+        session.setEnablePaimonRustReader(true);
+        session.enableFileScannerV2 = true;
+        PaimonScanNode node = Mockito.spy(newTestNode(new PlanNodeId(1), new TupleId(3), session));
+        PaimonSource source = Mockito.mock(PaimonSource.class);
+        DataTable table = Mockito.mock(DataTable.class, Mockito.RETURNS_DEEP_STUBS);
+        RowType rowType = DataTypes.ROW(DataTypes.INT());
+        Mockito.when(table.partitionKeys()).thenReturn(Collections.emptyList());
+        Mockito.when(table.rowType()).thenReturn(rowType);
+        // Native ORC eligibility now inspects historical types even for file aggregates.
+        Mockito.when(table.schemaManager().schema(1L).logicalRowType()).thenReturn(rowType);
+        Mockito.when(source.getPaimonTable()).thenReturn(table);
+        Mockito.when(source.getExternalTable()).thenReturn(Mockito.mock(PaimonExternalTable.class));
+        node.setSource(source);
+        setField(FileQueryScanNode.class, node, "fileSplitter",
+                new FileSplitter(64L * 1024 * 1024, 64L * 1024 * 1024, 0));
+        setField(PaimonScanNode.class, node, "storagePropertiesMap", Collections.emptyMap());
+        DataSplit dataSplit = Mockito.spy(createDataSplit(Arrays.asList("first.parquet", "second.orc")));
+        Mockito.doReturn(Collections.singletonList(dataSplit)).when(node).getPaimonSplitFromAPI();
+
+        // File aggregates need native file readers; enabling Rust must not replace raw-file splits
+        // with logical DataSplits, which require row reads for COUNT(col) and MIN/MAX.
+        for (TPushAggOp aggregate : Arrays.asList(TPushAggOp.COUNT, TPushAggOp.MINMAX)) {
+            node.setPushDownAggNoGrouping(aggregate);
+            node.setPushDownCountSlotIds(Collections.singletonList(new SlotId(7)));
+            List<org.apache.doris.spi.Split> splits = node.getSplits(1);
+            Assert.assertEquals(2, splits.size());
+            for (org.apache.doris.spi.Split split : splits) {
+                Assert.assertNull(((PaimonSplit) split).getSplit());
+                Assert.assertFalse(((PaimonSplit) split).getRowCount().isPresent());
+            }
+        }
+        Mockito.verify(dataSplit, Mockito.never()).mergedRowCount();
+    }
+
     @Test
     public void testCountColumnKeepsAllSplitsWhileCountStarUsesMergedRowCount() throws UserException {
         PaimonScanNode node = Mockito.spy(newTestNode(new PlanNodeId(1), new TupleId(3), sv));
@@ -1846,6 +1903,173 @@ public class PaimonScanNodeTest {
         Assert.assertTrue(rangeDesc.getTableFormatParams().getPaimonParams().isSetPaimonSplit());
     }
 
+    @Test
+    public void testRustReaderSelectionRequiresFileScannerV2() throws Exception {
+        // The V1 FileScanner explicitly rejects PAIMON_RUST, so a rust request may
+        // only be encoded when enable_file_scanner_v2 is on; with V2 disabled the
+        // split falls back to JNI so the selected scanner can consume it.
+        for (boolean scannerV2 : Arrays.asList(true, false)) {
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = scannerV2;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                    new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+            Mockito.when(paimonTable.schema()).thenReturn(new TableSchema(
+                    1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                    0, Collections.emptyList(), Collections.emptyList(),
+                    Collections.emptyMap(), null));
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+            Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+            setField(PaimonScanNode.class, node, "backendStorageProperties",
+                    ImmutableMap.of("AWS_ACCESS_KEY", "example-key", "AWS_SECRET_KEY", "example-secret"));
+            Mockito.when(externalTable.getDbName()).thenReturn("db");
+            Mockito.when(externalTable.getName()).thenReturn("t");
+            node.setSource(source);
+            // The rust gate and schema serialization use doInitialize's cached
+            // getProcessedTable() result, not the raw source table.
+            setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+            setField(PaimonScanNode.class, node, "storagePropertiesMap", Collections.emptyMap());
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("rust_gate.parquet")));
+
+            Assert.assertEquals(scannerV2 ? TPaimonReaderType.PAIMON_RUST : TPaimonReaderType.PAIMON_JNI,
+                    rangeDesc.getTableFormatParams().getPaimonParams().getReaderType());
+        }
+    }
+
+    @Test
+    public void testRustSchemaJsonShipsProcessedTableRelationOptions() throws Exception {
+        // Relation options such as t@options('read.batch-size'='1') are applied by
+        // getProcessedTable(); the JNI reader serializes that effective table, and the
+        // rust reader rebuilds its table from the shipped schema JSON, deriving its
+        // read batch size from the schema options. Only the processed table's schema
+        // carries the override here, and source.getPaimonTable() is deliberately left
+        // unstubbed (it returns null): if the serialization ever regresses to the raw
+        // cached table, the rust gate falls back to JNI and this test fails.
+        SessionVariable vars = new SessionVariable();
+        vars.setEnablePaimonRustReader(true);
+        vars.enableFileScannerV2 = true;
+
+        PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+
+        // The processed table: the relation override is merged into its schema.
+        FileStoreTable processed = Mockito.mock(FileStoreTable.class);
+        Mockito.when(processed.schema()).thenReturn(new TableSchema(
+                1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                0, Collections.emptyList(), Collections.emptyList(),
+                ImmutableMap.of("read.batch-size", "1"), null));
+
+        PaimonSource source = Mockito.mock(PaimonSource.class);
+        PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+        Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+        Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+        setField(PaimonScanNode.class, node, "backendStorageProperties",
+                ImmutableMap.of("AWS_ACCESS_KEY", "example-key", "AWS_SECRET_KEY", "example-secret"));
+        Mockito.when(externalTable.getDbName()).thenReturn("db");
+        Mockito.when(externalTable.getName()).thenReturn("t");
+        node.setSource(source);
+        setField(PaimonScanNode.class, node, "processedTable", processed);
+        setField(PaimonScanNode.class, node, "storagePropertiesMap", Collections.emptyMap());
+
+        TFileRangeDesc rangeDesc = new TFileRangeDesc();
+        invokePrivateMethod(node, "setPaimonParams",
+                new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                rangeDesc, new PaimonSplit(createDataSplit("relation_options.parquet")));
+
+        org.apache.doris.thrift.TPaimonFileDesc fileDesc =
+                rangeDesc.getTableFormatParams().getPaimonParams();
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST, fileDesc.getReaderType());
+        TableSchema shipped = org.apache.paimon.utils.JsonSerdeUtil.fromJson(
+                fileDesc.getPaimonTableSchemaJson(), TableSchema.class);
+        Assert.assertEquals("1", shipped.options().get("read.batch-size"));
+    }
+
+    @Test
+    public void testRustSchemaJsonStripsTimeTravelSelectors() throws Exception {
+        // A statement fence pins the data snapshot by merging scan.snapshot-id into the
+        // schema options (isolateSnapshotRead) while the fields keep the latest schema —
+        // a schema-only ALTER after the last data commit must stay visible. The rust
+        // reader pins data via the serialized DataSplit and re-resolves a shipped
+        // selector in copy_with_time_travel, swapping the fields back to the pinned
+        // snapshot's older schema; the shipped JSON must therefore carry the resolved
+        // fields without the planning selectors, keeping every other option.
+        // The derived scan.mode is stripped with it in every spelling: paimon accepts
+        // enum option values case-insensitively but preserves the original string in
+        // the options map, so a table can persist scan.mode=FROM-SNAPSHOT — the rust
+        // reader recognizes the value case-insensitively and would reject the now-bare
+        // uppercase mode for lacking its selector, while JNI accepts the original
+        // option.
+        for (String scanMode : new String[] {"from-snapshot", "FROM-SNAPSHOT", " From-Snapshot "}) {
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                    new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+
+            // Resolved (latest) schema: the column added after the last data commit is a
+            // field, and the fence snapshot id is only planning state in the options.
+            FileStoreTable processed = Mockito.mock(FileStoreTable.class);
+            Mockito.when(processed.schema()).thenReturn(new TableSchema(
+                    1, Arrays.asList(
+                            new DataField(0, "id", new IntType()),
+                            new DataField(1, "added_after", DataTypes.STRING())),
+                    1, Collections.emptyList(), Collections.emptyList(),
+                    ImmutableMap.of(
+                            "scan.snapshot-id", "3",
+                            "scan.tag-name", "stale-tag",
+                            // Paimon's copyInternal materializes the derived scan mode for
+                            // the merged selector; it dangles once the selector is stripped.
+                            "scan.mode", scanMode,
+                            "read.batch-size", "1"),
+                    null));
+
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+            Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+            setField(PaimonScanNode.class, node, "backendStorageProperties",
+                    ImmutableMap.of("AWS_ACCESS_KEY", "example-key", "AWS_SECRET_KEY", "example-secret"));
+            Mockito.when(externalTable.getDbName()).thenReturn("db");
+            Mockito.when(externalTable.getName()).thenReturn("t");
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", processed);
+            setField(PaimonScanNode.class, node, "storagePropertiesMap", Collections.emptyMap());
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("strip_selectors.parquet")));
+
+            org.apache.doris.thrift.TPaimonFileDesc fileDesc =
+                    rangeDesc.getTableFormatParams().getPaimonParams();
+            Assert.assertEquals("scan.mode " + scanMode, TPaimonReaderType.PAIMON_RUST,
+                    fileDesc.getReaderType());
+            TableSchema shipped = org.apache.paimon.utils.JsonSerdeUtil.fromJson(
+                    fileDesc.getPaimonTableSchemaJson(), TableSchema.class);
+            Assert.assertNull(shipped.options().get(CoreOptions.SCAN_SNAPSHOT_ID.key()));
+            Assert.assertNull(shipped.options().get(CoreOptions.SCAN_TAG_NAME.key()));
+            // The fence-materialized "from-snapshot" would otherwise dangle: the rust
+            // ReadBuilder requires a selector for it and rejects the open.
+            Assert.assertNull("scan.mode " + scanMode + " must be stripped",
+                    shipped.options().get(CoreOptions.SCAN_MODE.key()));
+            Assert.assertEquals("1", shipped.options().get("read.batch-size"));
+            // The resolved fields are transported untouched, including the column added
+            // after the last data commit.
+            Assert.assertEquals(
+                    Arrays.asList("id", "added_after"),
+                    shipped.fields().stream().map(DataField::name).collect(Collectors.toList()));
+        }
+    }
+
     @Test
     public void testGetFieldIndexMatchesMixedCaseColumns() {
         List<String> fieldNames = Arrays.asList("data", "mIxEd_COL", "PART");
@@ -2085,15 +2309,21 @@ public class PaimonScanNodeTest {
     }
 
     private DataSplit createDataSplit(String fileName) {
-        DataFileMeta dataFileMeta = DataFileMeta.forAppend(fileName, 64L * 1024 * 1024, 1L, SimpleStats.EMPTY_STATS,
-                1L, 1L, 1L, Collections.<String>emptyList(), null, FileSource.APPEND,
-                Collections.<String>emptyList(), null, null, Collections.<String>emptyList());
+        return createDataSplit(Collections.singletonList(fileName));
+    }
+
+    private DataSplit createDataSplit(List<String> fileNames) {
+        List<DataFileMeta> dataFileMetas = fileNames.stream().map(fileName ->
+                DataFileMeta.forAppend(fileName, 64L * 1024 * 1024, 1L, SimpleStats.EMPTY_STATS,
+                        1L, 1L, 1L, Collections.<String>emptyList(), null, FileSource.APPEND,
+                        Collections.<String>emptyList(), null, null, Collections.<String>emptyList()))
+                .collect(Collectors.toList());
         return DataSplit.builder()
                 .rawConvertible(true)
                 .withPartition(BinaryRow.singleColumn(1))
                 .withBucket(1)
                 .withBucketPath("file://b1")
-                .withDataFiles(Collections.singletonList(dataFileMeta))
+                .withDataFiles(dataFileMetas)
                 .build();
     }
 
@@ -2111,4 +2341,1311 @@ public class PaimonScanNodeTest {
         Mockito.when(dataSplit.deletionFiles()).thenReturn(Optional.empty());
         return dataSplit;
     }
+
+    @Test
+    public void testRustReaderSelectionRejectsFallbackSplits() throws Exception {
+        RustRoutingFixture f = new RustRoutingFixture();
+        DataOutputSerializer out = new DataOutputSerializer(1024);
+        f.split.serialize(out);
+        out.writeBoolean(true);
+        DataInputDeserializer in = new DataInputDeserializer();
+        in.setBuffer(out.getSharedBuffer(), 0, out.length());
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI,
+                f.reader(FallbackReadFileStoreTable.FallbackDataSplit.deserialize(in)));
+        setField(PaimonScanNode.class, f.node, "processedTable", Mockito.mock(FallbackReadFileStoreTable.class));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsQueryAuthTables() throws Exception {
+        RustRoutingFixture f = new RustRoutingFixture();
+        f.schema(1, new IntType(), ImmutableMap.of("query-auth.enabled", "true"), false);
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsUnsupportedPkDvModes() throws Exception {
+        // The pinned rust read_pk supports partial-update / aggregation tables
+        // with deletion vectors only in the fully materialized shape: it rejects
+        // deletion-vectors.merge-on-read=true outright, and otherwise requires
+        // every split to be compacted (level != 0) and known free of retract rows
+        // (DataSplit::is_fully_materialized_pk_dv). All other shapes are valid
+        // Java/JNI scans, so they must fall back here instead of failing the
+        // BE open. Deduplicate stays rust-eligible: its read_pk routes
+        // uncompacted DV splits to the KV reader, which applies the DVs.
+        SessionVariable vars = new SessionVariable();
+        vars.setEnablePaimonRustReader(true);
+        vars.enableFileScannerV2 = true;
+
+        // createDataSplit builds a level-0 file — the ordinary uncompacted shape.
+        DataSplit levelZeroSplit = createDataSplit("dv_level0.parquet");
+        DataSplit unknownDeleteCountSplit = dvSplitOverFiles(
+                dvCompactedFile("dv_unknown.parquet", null));
+        DataSplit retractsSplit = dvSplitOverFiles(
+                dvCompactedFile("dv_retracts.parquet", 1L));
+        DataSplit materializedSplit = dvSplitOverFiles(
+                dvCompactedFile("dv_materialized.parquet", 0L));
+
+        for (CoreOptions.MergeEngine engine : Arrays.asList(
+                CoreOptions.MergeEngine.PARTIAL_UPDATE, CoreOptions.MergeEngine.AGGREGATE)) {
+            // deletion-vectors.merge-on-read=true: rejected table-wide.
+            Assert.assertEquals("merge-on-read " + engine, TPaimonReaderType.PAIMON_JNI,
+                    dvReaderTypeOf(vars, engine, true,
+                            ImmutableMap.of("deletion-vectors.merge-on-read", "true"),
+                            materializedSplit));
+            // merge-on-read=false but the split still needs per-key merge work:
+            // level-0 data, unknown delete count, or known retracts — all fall
+            // back per split.
+            Assert.assertEquals("level-0 " + engine, TPaimonReaderType.PAIMON_JNI,
+                    dvReaderTypeOf(vars, engine, true, Collections.emptyMap(), levelZeroSplit));
+            Assert.assertEquals("unknown delete count " + engine, TPaimonReaderType.PAIMON_JNI,
+                    dvReaderTypeOf(vars, engine, true, Collections.emptyMap(), unknownDeleteCountSplit));
+            Assert.assertEquals("retracts " + engine, TPaimonReaderType.PAIMON_JNI,
+                    dvReaderTypeOf(vars, engine, true, Collections.emptyMap(), retractsSplit));
+            // A fully materialized compacted split reads raw on rust, and without
+            // deletion vectors the KV reader handles any split.
+            Assert.assertEquals("materialized " + engine, TPaimonReaderType.PAIMON_RUST,
+                    dvReaderTypeOf(vars, engine, true, Collections.emptyMap(), materializedSplit));
+            Assert.assertEquals("no dv " + engine, TPaimonReaderType.PAIMON_RUST,
+                    dvReaderTypeOf(vars, engine, false, Collections.emptyMap(), levelZeroSplit));
+        }
+
+        // Deduplicate keeps rust eligibility for uncompacted DV splits and for
+        // merge-on-read=true: the KV reader applies the attached per-file DVs.
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST,
+                dvReaderTypeOf(vars, CoreOptions.MergeEngine.DEDUPLICATE, true,
+                        ImmutableMap.of("deletion-vectors.merge-on-read", "true"), levelZeroSplit));
+    }
+
+    // Builds a node whose processed table is a primary-key table with the given
+    // merge engine and deletion-vector options (all other gates open: no
+    // query-auth, v2 on, ordinary DataSplit) and returns the reader type chosen
+    // for the given split.
+    private TPaimonReaderType dvReaderTypeOf(SessionVariable vars, CoreOptions.MergeEngine mergeEngine,
+            boolean deletionVectorsEnabled, Map<String, String> schemaOptions, DataSplit split)
+            throws Exception {
+        PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+        PaimonSource source = Mockito.mock(PaimonSource.class);
+        FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+        // lenient: the schema / external-table stubs are only consumed when the
+        // split stays rust-eligible.
+        Mockito.lenient().when(paimonTable.schema()).thenReturn(new TableSchema(
+                1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                0, Collections.emptyList(), Collections.emptyList(), schemaOptions, null));
+        CoreOptions options = Mockito.mock(CoreOptions.class);
+        Mockito.when(paimonTable.coreOptions()).thenReturn(options);
+        Mockito.when(options.queryAuthEnabled()).thenReturn(false);
+        Mockito.when(options.mergeEngine()).thenReturn(mergeEngine);
+        Mockito.when(options.deletionVectorsEnabled()).thenReturn(deletionVectorsEnabled);
+        PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+        Mockito.lenient().when(source.getExternalTable()).thenReturn(externalTable);
+        Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+        setField(PaimonScanNode.class, node, "backendStorageProperties",
+                ImmutableMap.of("AWS_ACCESS_KEY", "example-key", "AWS_SECRET_KEY", "example-secret"));
+        Mockito.lenient().when(externalTable.getDbName()).thenReturn("db");
+        Mockito.lenient().when(externalTable.getName()).thenReturn("t");
+        node.setSource(source);
+        setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+
+        TFileRangeDesc rangeDesc = new TFileRangeDesc();
+        invokePrivateMethod(node, "setPaimonParams",
+                new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                rangeDesc, new PaimonSplit(split));
+        return rangeDesc.getTableFormatParams().getPaimonParams().getReaderType();
+    }
+
+    // A compacted (level != 0) data file with an explicit retract-row count:
+    // null = unknown, 0 = fully materialized, >0 = rows still to retract.
+    private static DataFileMeta dvCompactedFile(String fileName, Long deleteRowCount) {
+        return DataFileMeta.create(fileName, 64L * 1024 * 1024, 1L,
+                DataFileMeta.EMPTY_MIN_KEY, DataFileMeta.EMPTY_MAX_KEY,
+                SimpleStats.EMPTY_STATS, SimpleStats.EMPTY_STATS,
+                1L, 1L, 1L, 5, deleteRowCount, null,
+                FileSource.APPEND, Collections.emptyList(), null, Collections.emptyList());
+    }
+
+    private static DataSplit dvSplitOverFiles(DataFileMeta... files) {
+        return DataSplit.builder()
+                .rawConvertible(true)
+                .withPartition(BinaryRow.singleColumn(1))
+                .withBucket(1)
+                .withBucketPath("file://b1")
+                .withDataFiles(Arrays.asList(files))
+                .build();
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsOrcTimestampLtzSchemas() throws Exception {
+        RustRoutingFixture f = new RustRoutingFixture();
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST, f.reader(createDataSplit("plain.orc")));
+        f.schema(1, new org.apache.paimon.types.LocalZonedTimestampType(6), Collections.emptyMap(), false);
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST, f.reader(f.split));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, f.reader(createDataSplit("ltz.orc")));
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsMixedFormatOrcLtzSplits() throws Exception {
+        // Paimon allows per-level file.format, so one DataSplit can mix
+        // Parquet and ORC members. The old gate read only the split path's
+        // suffix (the first file), so a first-Parquet/later-ORC split with an
+        // LTZ schema passed while rust still applied the shifted ORC decode
+        // to the ORC members. The format now comes from every member file.
+        SessionVariable vars = new SessionVariable();
+        vars.setEnablePaimonRustReader(true);
+        vars.enableFileScannerV2 = true;
+        List<DataField> ltzFields = Arrays.asList(
+                new DataField(0, "id", new IntType()),
+                new DataField(1, "ts_ltz", new LocalZonedTimestampType(6)));
+
+        // First file Parquet, later member ORC: the first file's suffix no
+        // longer speaks for the split -> JNI.
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI,
+                readerTypeOf(vars, ltzFields, "first.parquet", "second.orc"));
+        // ORC first, later Parquet member (the shape the suffix check did
+        // catch) stays JNI.
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI,
+                readerTypeOf(vars, ltzFields, "first.orc", "second.parquet"));
+        // All-Parquet members with the same LTZ schema stay rust-eligible.
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST,
+                readerTypeOf(vars, ltzFields, "a.parquet", "b.parquet"));
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsNestedTimestampLtzSchemas() throws Exception {
+        // An LTZ nested under MAP/ARRAY/ROW reaches the same shifted ORC
+        // decode through the container's field materialization; the old
+        // top-level-only type-root check missed it. The search recurses; the
+        // same containers without LTZ stay rust-eligible, and Parquet splits
+        // with nested LTZ are unaffected (only the ORC decoder diverges).
+        SessionVariable vars = new SessionVariable();
+        vars.setEnablePaimonRustReader(true);
+        vars.enableFileScannerV2 = true;
+        LocalZonedTimestampType ltz = new LocalZonedTimestampType(6);
+
+        // LTZ under each container kind, uniform-ORC split -> JNI.
+        for (DataField nested : Arrays.asList(
+                new DataField(1, "m", new org.apache.paimon.types.MapType(new IntType(), ltz)),
+                new DataField(1, "arr", new org.apache.paimon.types.ArrayType(ltz)),
+                new DataField(1, "r",
+                        new RowType(Collections.singletonList(new DataField(0, "ts_ltz", ltz)))))) {
+            Assert.assertEquals("nested " + nested.name() + " under ORC",
+                    TPaimonReaderType.PAIMON_JNI,
+                    readerTypeOf(vars, Arrays.asList(
+                            new DataField(0, "id", new IntType()), nested),
+                            "nested.orc"));
+            // The same nested-LTZ schema over Parquet stays rust-eligible.
+            Assert.assertEquals("nested " + nested.name() + " over Parquet",
+                    TPaimonReaderType.PAIMON_RUST,
+                    readerTypeOf(vars, Arrays.asList(
+                            new DataField(0, "id", new IntType()), nested),
+                            "nested.parquet"));
+        }
+
+        // Containers without any LTZ over ORC stay rust-eligible.
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST,
+                readerTypeOf(vars, Arrays.asList(
+                        new DataField(0, "id", new IntType()),
+                        new DataField(1, "m",
+                                new org.apache.paimon.types.MapType(new IntType(), new IntType()))),
+                        "map_int.orc"));
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsExternalDataFileSplits() throws Exception {
+        // data-file.external-paths: paimon can store an absolute location in
+        // each DataFileMeta, and both Java and the serialized rust split prefer
+        // it over the bucket path — but the pinned rust table builds one FileIO
+        // from paimon_table, whose storage enum parses every file with the
+        // warehouse-selected backend: an hdfs table with an s3:// external file
+        // (or an s3 table with an oss:// file) reaches the wrong parser and
+        // fails the open, while JNI reads it. Any split carrying an external
+        // file therefore routes to JNI, regardless of the external path's own
+        // scheme (the shipped options describe only the warehouse).
+        SessionVariable vars = new SessionVariable();
+        vars.setEnablePaimonRustReader(true);
+        vars.enableFileScannerV2 = true;
+        List<DataField> fields = Arrays.asList(
+                new DataField(0, "id", new IntType()),
+                new DataField(1, "ts", new TimestampType(6)));
+
+        // A split whose file carries an external path -> JNI.
+        DataFileMeta external = DataFileMeta.forAppend(
+                "external.parquet", 64L * 1024 * 1024, 1L, SimpleStats.EMPTY_STATS,
+                1L, 1L, 1L, Collections.<String>emptyList(), null, FileSource.APPEND,
+                Collections.<String>emptyList(), null, null, Collections.<String>emptyList())
+                .newExternalPath("hdfs://other-nn/external/wh/external.parquet");
+        DataSplit externalSplit = DataSplit.builder()
+                .rawConvertible(true)
+                .withPartition(BinaryRow.singleColumn(1))
+                .withBucket(1)
+                .withBucketPath("file://b1")
+                .withDataFiles(Collections.singletonList(external))
+                .build();
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI,
+                readerTypeOfSplit(vars, fields, externalSplit));
+
+        // The same split without an external path stays rust-eligible.
+        DataSplit plainSplit = DataSplit.builder()
+                .rawConvertible(true)
+                .withPartition(BinaryRow.singleColumn(1))
+                .withBucket(1)
+                .withBucketPath("file://b1")
+                .withDataFiles(Collections.singletonList(DataFileMeta.forAppend(
+                        "plain.parquet", 64L * 1024 * 1024, 1L, SimpleStats.EMPTY_STATS,
+                        1L, 1L, 1L, Collections.<String>emptyList(), null, FileSource.APPEND,
+                        Collections.<String>emptyList(), null, null, Collections.<String>emptyList())))
+                .build();
+        Assert.assertEquals(TPaimonReaderType.PAIMON_RUST,
+                readerTypeOfSplit(vars, fields, plainSplit));
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsDeduplicateIgnoreDeleteTables() throws Exception {
+        // deduplicate.ignore-delete=true tables stay on JNI: Java's
+        // DeduplicateMergeFunction skips retract records when the option is set
+        // (including old, uncompacted files that still contain them), but the
+        // pinned rust read_pk does not pass table options into its deduplicate
+        // merge — it picks the latest row and omits the key when that row is
+        // DELETE/UPDATE_BEFORE. An uncompacted insert followed by a delete
+        // therefore returns the insert through JNI but silently disappears
+        // through rust.
+        for (Object[] shape : new Object[][] {
+                // DEDUPLICATE engine with the option on -> JNI.
+                {CoreOptions.MergeEngine.DEDUPLICATE, true, TPaimonReaderType.PAIMON_JNI},
+                // Option off, same engine -> rust.
+                {CoreOptions.MergeEngine.DEDUPLICATE, false, TPaimonReaderType.PAIMON_RUST},
+                // Option on under a non-deduplicate engine: meaningless there,
+                // the gate must not reject -> rust.
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE, true, TPaimonReaderType.PAIMON_RUST}}) {
+            CoreOptions.MergeEngine mergeEngine = (CoreOptions.MergeEngine) shape[0];
+            boolean ignoreDelete = (Boolean) shape[1];
+            TPaimonReaderType expected = (TPaimonReaderType) shape[2];
+
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                    new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+            CoreOptions options = Mockito.mock(CoreOptions.class);
+            Mockito.when(paimonTable.coreOptions()).thenReturn(options);
+            Mockito.when(options.queryAuthEnabled()).thenReturn(false);
+            Mockito.when(options.mergeEngine()).thenReturn(mergeEngine);
+            Mockito.when(options.ignoreDelete()).thenReturn(ignoreDelete);
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+            Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+            setField(PaimonScanNode.class, node, "backendStorageProperties",
+                    ImmutableMap.of("AWS_ACCESS_KEY", "example-key", "AWS_SECRET_KEY", "example-secret"));
+            Mockito.when(externalTable.getDbName()).thenReturn("db");
+            Mockito.when(externalTable.getName()).thenReturn("t");
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+            if (expected == TPaimonReaderType.PAIMON_RUST) {
+                // Only the rust branch serializes the schema (a JNI-expected
+                // stub would trip Mockito's strict unused-stubbing check).
+                Mockito.when(paimonTable.schema()).thenReturn(new TableSchema(
+                        1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                        0, Collections.emptyList(), Collections.emptyList(),
+                        Collections.emptyMap(), null));
+            }
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("dedup_ignore_del.parquet")));
+            Assert.assertEquals("engine " + mergeEngine + ", ignore-delete " + ignoreDelete,
+                    expected, rangeDesc.getTableFormatParams().getPaimonParams().getReaderType());
+        }
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsRestTokenTables() throws Exception {
+        // doInitialize snapshots RESTTokenFileIO.validToken().token() into
+        // the backend storage properties, discarding expireAtMillis and the
+        // REST refresh context, so the shipped credentials look static — but
+        // the pinned rust table reuses one option map with no refresh
+        // callback, while paimon 1.4.2's JNI RESTTokenFileIO checks expiry
+        // before each file operation and obtains a replacement token. A scan
+        // crossing the token TTL would start on rust and later fail
+        // authentication; only the table's FileIO type reveals the token is
+        // renewable.
+        for (boolean restToken : new boolean[] {true, false}) {
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                    new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+            CoreOptions options = Mockito.mock(CoreOptions.class);
+            Mockito.when(paimonTable.coreOptions()).thenReturn(options);
+            Mockito.when(options.queryAuthEnabled()).thenReturn(false);
+            // S3-location static credentials, an ordinary shape the
+            // REST-token snapshot also produces.
+            setField(PaimonScanNode.class, node, "backendStorageProperties", ImmutableMap.of(
+                    "AWS_CREDENTIALS_PROVIDER_TYPE", "DEFAULT",
+                    "AWS_ACCESS_KEY", "ak",
+                    "AWS_SECRET_KEY", "sk",
+                    "AWS_ENDPOINT", "http://127.0.0.1:19001",
+                    "AWS_REGION", "us-east-1"));
+            if (restToken) {
+                Mockito.when(paimonTable.fileIO())
+                        .thenReturn(Mockito.mock(RESTTokenFileIO.class));
+                // The JNI branch never serializes the schema.
+            } else {
+                Mockito.when(paimonTable.schema()).thenReturn(new TableSchema(
+                        1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                        0, Collections.emptyList(), Collections.emptyList(),
+                        Collections.emptyMap(), null));
+                // A plain (or unresolved) FileIO shape stays rust-eligible.
+                Mockito.when(paimonTable.fileIO()).thenReturn(null);
+            }
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+            Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+            Mockito.when(externalTable.getDbName()).thenReturn("db");
+            Mockito.when(externalTable.getName()).thenReturn("t");
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("rest_token.parquet")));
+            Assert.assertEquals("RESTTokenFileIO=" + restToken,
+                    restToken ? TPaimonReaderType.PAIMON_JNI : TPaimonReaderType.PAIMON_RUST,
+                    rangeDesc.getTableFormatParams().getPaimonParams().getReaderType());
+        }
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsRustUnsupportedMergeOptions() throws Exception {
+        // Java supports partial-update.remove-record-on-delete /
+        // aggregation.remove-record-on-delete and the wider per-field option
+        // matrix, but the pinned paimon-rust PartialUpdateConfig /
+        // AggregationConfig validations return Unsupported for them — the
+        // rust predicates check option-key PRESENCE, so the FE gate mirrors
+        // presence exactly (an off value still routes to JNI, because the
+        // rust merge construction rejects the bare key). Non-DV tables are
+        // the review's case: without this gate an ordinary DataSplit passes
+        // the compound gate and fails during the rust merge construction
+        // instead of scanning through JNI.
+        for (Object[] shape : new Object[][] {
+                // Partial-update unsupported keys -> JNI.
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE, "partial-update.remove-record-on-delete",
+                        TPaimonReaderType.PAIMON_JNI},
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE,
+                        "partial-update.remove-record-on-sequence-group",
+                        TPaimonReaderType.PAIMON_JNI},
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE, "fields.v.ignore-retract",
+                        TPaimonReaderType.PAIMON_JNI},
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE, "fields.v.distinct",
+                        TPaimonReaderType.PAIMON_JNI},
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE, "fields.v.ignore-delete",
+                        TPaimonReaderType.PAIMON_JNI},
+                // Aggregation unsupported keys -> JNI.
+                {CoreOptions.MergeEngine.AGGREGATE, "aggregation.remove-record-on-delete",
+                        TPaimonReaderType.PAIMON_JNI},
+                {CoreOptions.MergeEngine.AGGREGATE, "fields.v.sequence-group",
+                        TPaimonReaderType.PAIMON_JNI},
+                {CoreOptions.MergeEngine.AGGREGATE, "ignore-delete",
+                        TPaimonReaderType.PAIMON_JNI},
+                // Keys the rust read path DOES support: bare /
+                // partial-update-prefixed ignore-delete, sequence groups and
+                // field aggregation stay rust-eligible.
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE, "partial-update.ignore-delete",
+                        TPaimonReaderType.PAIMON_RUST},
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE, "fields.v.sequence-group",
+                        TPaimonReaderType.PAIMON_RUST},
+                {CoreOptions.MergeEngine.PARTIAL_UPDATE, "fields.v.aggregate-function",
+                        TPaimonReaderType.PAIMON_RUST},
+                {CoreOptions.MergeEngine.AGGREGATE, "fields.v.aggregate-function",
+                        TPaimonReaderType.PAIMON_RUST},
+                {CoreOptions.MergeEngine.AGGREGATE, "fields.default-aggregate-function",
+                        TPaimonReaderType.PAIMON_RUST}}) {
+            CoreOptions.MergeEngine mergeEngine = (CoreOptions.MergeEngine) shape[0];
+            String optionKey = (String) shape[1];
+            TPaimonReaderType expected = (TPaimonReaderType) shape[2];
+
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                    new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+            CoreOptions options = Mockito.mock(CoreOptions.class);
+            Mockito.when(paimonTable.coreOptions()).thenReturn(options);
+            Mockito.when(options.queryAuthEnabled()).thenReturn(false);
+            Mockito.when(options.mergeEngine()).thenReturn(mergeEngine);
+            Mockito.when(paimonTable.schema()).thenReturn(new TableSchema(
+                    1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                    0, Collections.emptyList(), Collections.emptyList(),
+                    ImmutableMap.of(optionKey, "true"), null));
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+            Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+            setField(PaimonScanNode.class, node, "backendStorageProperties",
+                    ImmutableMap.of("AWS_ACCESS_KEY", "example-key", "AWS_SECRET_KEY", "example-secret"));
+            Mockito.when(externalTable.getDbName()).thenReturn("db");
+            Mockito.when(externalTable.getName()).thenReturn("t");
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("merge_option_gate.parquet")));
+            Assert.assertEquals("engine " + mergeEngine + ", option " + optionKey, expected,
+                    rangeDesc.getTableFormatParams().getPaimonParams().getReaderType());
+        }
+    }
+
+    // Builds a node whose processed table carries the given row-type fields
+    // (all other gates open: no query-auth, v2 on, ordinary DataSplit) and
+    // returns the reader type chosen for a split over the given member files.
+    // Multiple file names build a multi-file split (paimon allows per-level
+    // file.format, so one DataSplit can mix Parquet and ORC members).
+    private TPaimonReaderType readerTypeOf(SessionVariable vars, List<DataField> fields,
+            String... fileNames) throws Exception {
+        return readerTypeOfSplit(vars, fields, createDataSplit(Arrays.asList(fileNames)));
+    }
+
+    // Same harness as readerTypeOf over an externally built DataSplit (e.g. one
+    // whose member files carry data-file.external-paths locations).
+    private TPaimonReaderType readerTypeOfSplit(SessionVariable vars, List<DataField> fields,
+            DataSplit dataSplit) throws Exception {
+        PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+        PaimonSource source = Mockito.mock(PaimonSource.class);
+        FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+        Mockito.when(paimonTable.schema()).thenReturn(new TableSchema(
+                1, fields, 0, Collections.emptyList(), Collections.emptyList(),
+                Collections.emptyMap(), null));
+        CoreOptions options = Mockito.mock(CoreOptions.class);
+        Mockito.when(paimonTable.coreOptions()).thenReturn(options);
+        Mockito.when(options.queryAuthEnabled()).thenReturn(false);
+        PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+        Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+        Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+        setField(PaimonScanNode.class, node, "backendStorageProperties",
+                ImmutableMap.of("AWS_ACCESS_KEY", "example-key", "AWS_SECRET_KEY", "example-secret"));
+        Mockito.when(externalTable.getDbName()).thenReturn("db");
+        Mockito.when(externalTable.getName()).thenReturn("t");
+        node.setSource(source);
+        setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+
+        TFileRangeDesc rangeDesc = new TFileRangeDesc();
+        invokePrivateMethod(node, "setPaimonParams",
+                new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                rangeDesc, new PaimonSplit(dataSplit));
+        return rangeDesc.getTableFormatParams().getPaimonParams().getReaderType();
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsIncrementalScans() throws Exception {
+        for (String mode : Arrays.asList("delta", "changelog", "diff")) {
+            RustRoutingFixture f = new RustRoutingFixture();
+            f.node.setScanParams(new TableScanParams("incr", ImmutableMap.of(
+                    "incremental-between", "5,10", "incremental-between-scan-mode", mode), null));
+            Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+        }
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsAmbientCredentialProviderModes() throws Exception {
+        for (String provider : Arrays.asList("ENV", "SYSTEM_PROPERTIES", "WEB_IDENTITY",
+                "CONTAINER", "INSTANCE_PROFILE")) {
+            RustRoutingFixture f = new RustRoutingFixture();
+            f.credentials(provider);
+            Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+        }
+    }
+
+    @Test
+    public void testRustReaderSelectionRequiresExplicitS3Credentials() throws Exception {
+        // DEFAULT without static keys may use JVM-only credentials or anonymous fallback.
+        for (String[] shape : new String[][] {
+                {"ANONYMOUS", "s3://warehouse/wh", "PAIMON_RUST", "", ""},
+                {"DEFAULT", "s3://warehouse/wh", "PAIMON_JNI", "", ""},
+                {"DEFAULT", "s3a://warehouse/wh", "PAIMON_JNI", "example-key", ""},
+                {"DEFAULT", "s3://warehouse/wh", "PAIMON_JNI", " ", " "},
+                {"DEFAULT", "s3://warehouse/wh", "PAIMON_RUST", "example-key", "example-secret"},
+                {"", "s3://warehouse/wh", "PAIMON_JNI", "", ""},
+                {"ANONYMOUS", "oss://warehouse/wh", "PAIMON_JNI", "", ""}}) {
+            String mode = shape[0];
+            String location = shape[1];
+            TPaimonReaderType expected =
+                    TPaimonReaderType.valueOf(shape[2]);
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                    new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            Mockito.when(source.getTableLocation()).thenReturn(location);
+            FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            // Supply both paths so an incorrect Rust selection fails the
+            // reader-type assertion rather than unrelated schema setup.
+            Mockito.lenient().when(source.getExternalTable()).thenReturn(externalTable);
+            Mockito.lenient().when(paimonTable.schema()).thenReturn(new TableSchema(
+                    1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                    0, Collections.emptyList(), Collections.emptyList(),
+                    Collections.emptyMap(), null));
+            Mockito.lenient().when(externalTable.getDbName()).thenReturn("db");
+            Mockito.lenient().when(externalTable.getName()).thenReturn("t");
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+            setField(PaimonScanNode.class, node, "backendStorageProperties",
+                    ImmutableMap.of("AWS_CREDENTIALS_PROVIDER_TYPE", mode,
+                            "AWS_ACCESS_KEY", shape[3], "AWS_SECRET_KEY", shape[4]));
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("provider_ok.parquet")));
+            Assert.assertEquals("mode " + mode + " on " + location, expected,
+                    rangeDesc.getTableFormatParams().getPaimonParams().getReaderType());
+        }
+    }
+
+    @Test
+    public void testRustReaderRejectsConflictingStaticCredentials() throws Exception {
+        for (Map<String, String> extra : Arrays.asList(
+                ImmutableMap.of("AWS_CREDENTIALS_PROVIDER_TYPE", "ANONYMOUS"),
+                ImmutableMap.of("AWS_ROLE_ARN", "arn:aws:iam::123456789012:role/example"),
+                ImmutableMap.of("AWS_TOKEN", "expired-example-token"),
+                ImmutableMap.of("AWS_TOKEN", " "), ImmutableMap.of("AWS_ROLE_ARN", " "))) {
+            RustRoutingFixture f = new RustRoutingFixture();
+            Map<String, String> properties = new HashMap<>();
+            properties.put("AWS_ACCESS_KEY", "example-key");
+            properties.put("AWS_SECRET_KEY", "example-secret");
+            properties.putAll(extra);
+            setField(PaimonScanNode.class, f.node, "backendStorageProperties", properties);
+            // Hadoop's Simple provider ignores these settings; Rust must not change identity.
+            Assert.assertEquals(extra.toString(), TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+        }
+    }
+
+    @Test
+    public void testRustReaderRejectsUnsupportedPersistedBranchScanModes() throws Exception {
+        for (String mode : Arrays.asList("latest", "latest-full", "full", "compacted-full", "LATEST-FULL", " default ")) {
+            RustRoutingFixture f = new RustRoutingFixture();
+            f.schema(1, new IntType(), ImmutableMap.of("branch", "audit", "scan.mode", mode), false);
+            // A branch can keep its persisted mode without a time-travel selector to strip it.
+            Assert.assertEquals(mode, TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+        }
+    }
+
+    @Test
+    public void testRustReaderSelectionGatesUnverifiedLocationSchemes() throws Exception {
+        // The pinned paimon-rust storage dispatcher (io/storage.rs) selects the
+        // FileIO parser from the table location's URI scheme, and libpaimon_c.a
+        // compiles in separate COS, OBS, GCS and Azdls parsers besides OSS and
+        // S3. Doris normalizes those object stores' credentials into the AWS_*
+        // aliases, which the BE rust bridge translates only into the s3.* /
+        // fs.oss.* key families — a cosn:// / obs:// / gs:// / abfs:// table
+        // would reach its scheme's parser without the key family it reads
+        // (fs.cosn.userinfo.*, fs.obs.*, gcs.*, azure.*) and fail the open.
+        // Those schemes must fall back to JNI before the split is encoded;
+        // the verified schemes (s3 / s3a / oss, plus credential-free hdfs and
+        // local paths) stay rust-eligible.
+        for (String[] shape : new String[][] {
+                // Unverified object-store schemes -> JNI (property translation
+                // not implemented; Doris sends these as AWS_* aliases).
+                {"cosn://bucket/wh/db.db/t", "PAIMON_JNI"},
+                {"obs://bucket/wh/db.db/t", "PAIMON_JNI"},
+                {"gs://bucket/wh/db.db/t", "PAIMON_JNI"},
+                {"abfs://bucket/wh/db.db/t", "PAIMON_JNI"},
+                {"abfss://bucket@account/wh/db.db/t", "PAIMON_JNI"},
+                {"az://bucket/wh/db.db/t", "PAIMON_JNI"},
+                {"azure://bucket/wh/db.db/t", "PAIMON_JNI"},
+                // Uppercase URI-scheme variants of otherwise verified schemes:
+                // the crate strips only a lowercase prefix, and the DataSplit's
+                // file paths carry the original casing too -> JNI.
+                {"S3://bucket/wh/db.db/t", "PAIMON_JNI"},
+                {"Hdfs://nn/wh/db.db/t", "PAIMON_JNI"},
+                // A null location cannot be verified (and cannot ship
+                // paimon_table for the rust reader) -> JNI.
+                {null, "PAIMON_JNI"},
+                // Verified schemes with implemented property translation or
+                // credential-free parsers -> rust.
+                {"s3://bucket/wh/db.db/t", "PAIMON_RUST"},
+                {"s3a://bucket/wh/db.db/t", "PAIMON_RUST"},
+                {"oss://bucket/wh/db.db/t", "PAIMON_RUST"},
+                {"hdfs://nn/wh/db.db/t", "PAIMON_RUST"},
+                {"file:///paimon/wh/db.db/t", "PAIMON_RUST"},
+                {"/paimon/wh/db.db/t", "PAIMON_RUST"}}) {
+            String location = shape[0];
+            TPaimonReaderType expected = TPaimonReaderType.valueOf(shape[1]);
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                    new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            if (location != null) {
+                Mockito.when(source.getTableLocation()).thenReturn(location);
+            }
+            FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            if (expected == TPaimonReaderType.PAIMON_RUST) {
+                // Only the rust path serializes the schema and the table
+                // identity; the JNI iterations must not leave unused stubs.
+                Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+                Mockito.when(paimonTable.schema()).thenReturn(new TableSchema(
+                        1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                        0, Collections.emptyList(), Collections.emptyList(),
+                        Collections.emptyMap(), null));
+                Mockito.when(externalTable.getDbName()).thenReturn("db");
+                Mockito.when(externalTable.getName()).thenReturn("t");
+            }
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+            // Production-shaped COS/OBS/GCS/Azure map: static credentials
+            // normalized to the AWS_* aliases (plus the S3-compatible
+            // connection settings).
+            setField(PaimonScanNode.class, node, "backendStorageProperties", ImmutableMap.of(
+                    "AWS_CREDENTIALS_PROVIDER_TYPE", "DEFAULT",
+                    "AWS_ACCESS_KEY", "ak",
+                    "AWS_SECRET_KEY", "sk",
+                    "AWS_ENDPOINT", "http://127.0.0.1:19001",
+                    "AWS_REGION", "us-east-1"));
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("scheme_gate.parquet")));
+            Assert.assertEquals("location " + location, expected,
+                    rangeDesc.getTableFormatParams().getPaimonParams().getReaderType());
+        }
+    }
+
+    @Test
+    public void testIsRustVerifiedLocationSchemeClassification() {
+        // A plain path without a URI scheme is a local-filesystem location, which
+        // reads through the crate's LocalFs parser and needs no credentials. The
+        // scheme must appear in the exact lowercase form the pinned crate
+        // consumes: it lowercases only its storage dispatch, while its path
+        // extraction strips a lowercase prefix from the original string — a
+        // S3:// or Hdfs:// warehouse must route to JNI instead (the Java stack
+        // is case-insensitive everywhere).
+        Assert.assertTrue(PaimonScanNode.isRustVerifiedLocationScheme("s3://bucket/wh"));
+        Assert.assertTrue(PaimonScanNode.isRustVerifiedLocationScheme("/local/wh"));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedLocationScheme("S3://bucket/wh"));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedLocationScheme("OSS://bucket/wh"));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedLocationScheme("Hdfs://nn/wh"));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedLocationScheme("FILE:///paimon/wh"));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedLocationScheme("cosn://bucket/wh"));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedLocationScheme("COSN://bucket/wh"));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedLocationScheme("viewfs://cluster/wh"));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedLocationScheme(null));
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsAuthenticatedHdfsBackends() throws Exception {
+        // The pinned paimon-rust HDFS parser reads only the hdfs.name-node /
+        // hdfs.enable-append keys: no kerberos, no proxy user, no hadoop HA
+        // resolution. A Kerberized HDFS catalog (or one with hadoop.username /
+        // HA nameservice config) passes the location scheme gate, so the scan
+        // would open as the BE process's ambient identity and fail the access
+        // JNI honors — those shapes must fall back to JNI; the credential-free
+        // shape stays rust-eligible.
+        for (String[][] shape : new String[][][] {
+                // Production-shaped credential-free HdfsProperties output: an
+                // fs.defaultFS, the always-written simple-auth markers and
+                // pass-through tunables, none of which carry an identity.
+                {new String[] {"fs.defaultFS", "hdfs://nn:8020"},
+                        new String[] {"hdfs.security.authentication", "simple"},
+                        new String[] {"ipc.client.fallback-to-simple-auth-allowed", "true"},
+                        new String[] {"PAIMON_RUST"}},
+                // A null / empty map is the anonymous default the parser
+                // supports (the regression suite's plain hdfs:// table).
+                {new String[] {"PAIMON_RUST"}},
+                // Kerberos: authentication type plus principal / keytab, as
+                // HdfsProperties emits them for a Kerberized catalog.
+                {new String[] {"hadoop.security.authentication", "kerberos"},
+                        new String[] {"hadoop.kerberos.principal", "hdfs/_HOST@REALM"},
+                        new String[] {"hadoop.kerberos.keytab", "/etc/security/hdfs.keytab"},
+                        new String[] {"PAIMON_JNI"}},
+                {new String[] {"hdfs.security.authentication", "kerberos"}, new String[] {"PAIMON_JNI"}},
+                // Proxy user: hadoop.username authenticates as a specific user
+                // the rust parser would drop.
+                {new String[] {"hadoop.username", "hive"}, new String[] {"PAIMON_JNI"}},
+                // HA nameservice resolution: dfs.nameservices / dfs.ha.* config
+                // cannot be resolved without the dfs.* channel.
+                {new String[] {"dfs.nameservices", "ns1"},
+                        new String[] {"dfs.ha.namenodes.ns1", "nn1,nn2"},
+                        new String[] {"PAIMON_JNI"}},
+                {new String[] {"dfs.ha.namenodes.ns1", "nn1,nn2"}, new String[] {"PAIMON_JNI"}},
+                // Simple-auth client options: valid without Kerberos, but the
+                // rust storage_hdfs parser drops every dfs./hadoop./fs. key
+                // beyond its two, and hdfs-native defaults
+                // dfs.client.use.datanode.hostname to false — behind
+                // containers/NAT it connects to the advertised IP instead of
+                // the hostname and fails while JNI succeeds.
+                {new String[] {"dfs.client.use.datanode.hostname", "true"},
+                        new String[] {"PAIMON_JNI"}},
+                {new String[] {"hadoop.proxyuser.kerberos", "x"},
+                        new String[] {"PAIMON_JNI"}}}) {
+            Map<String, String> backendProperties = new HashMap<>();
+            for (int i = 0; i < shape.length - 1; i++) {
+                backendProperties.put(shape[i][0], shape[i][1]);
+            }
+            TPaimonReaderType expected = TPaimonReaderType.valueOf(
+                    shape[shape.length - 1][0]);
+
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0),
+                    new TupleDescriptor(new TupleId(0)), false, vars, ScanContext.EMPTY);
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            Mockito.when(source.getTableLocation()).thenReturn("hdfs://nn/wh/db.db/t");
+            FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            if (expected == TPaimonReaderType.PAIMON_RUST) {
+                Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+                Mockito.when(paimonTable.schema()).thenReturn(new TableSchema(
+                        1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                        0, Collections.emptyList(), Collections.emptyList(),
+                        Collections.emptyMap(), null));
+                Mockito.when(externalTable.getDbName()).thenReturn("db");
+                Mockito.when(externalTable.getName()).thenReturn("t");
+            }
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+            setField(PaimonScanNode.class, node, "backendStorageProperties", backendProperties);
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("hdfs_auth_gate.parquet")));
+            Assert.assertEquals("backend " + backendProperties, expected,
+                    rangeDesc.getTableFormatParams().getPaimonParams().getReaderType());
+        }
+    }
+
+    @Test
+    public void testIsRustVerifiedHdfsBackendClassification() {
+        // The anonymous / null map is the open-tested shape; the always-written
+        // simple-auth markers and fs.defaultFS carry no identity; every
+        // auth-bearing key (type, principal, keytab, proxy user) and the HA
+        // nameservice config route to JNI.
+        Assert.assertTrue(PaimonScanNode.isRustVerifiedHdfsBackend(null));
+        Assert.assertTrue(PaimonScanNode.isRustVerifiedHdfsBackend(Collections.emptyMap()));
+        Assert.assertTrue(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "fs.defaultFS", "hdfs://nn:8020",
+                "hadoop.security.authentication", "simple")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "hadoop.security.authentication", "kerberos")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "hadoop.security.authentication", "KERBEROS")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "hadoop.kerberos.principal", "hdfs/_HOST@REALM")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "hadoop.kerberos.keytab", "/etc/security/hdfs.keytab")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "hadoop.username", "hive")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "dfs.nameservices", "ns1")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "dfs.ha.namenodes.ns1", "nn1,nn2")));
+        // A blank value is the unset case (values are null-filtered upstream,
+        // but a blank site-config entry must not gate an otherwise
+        // credential-free catalog).
+        Assert.assertTrue(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "hadoop.username", " ")));
+        // The always-shipped inert keys stay rust-eligible together.
+        Assert.assertTrue(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "fs.defaultFS", "hdfs://nn:8020",
+                "hadoop.security.authentication", "simple",
+                "hdfs.security.authentication", "simple",
+                "ipc.client.fallback-to-simple-auth-allowed", "true")));
+        // Any other dfs./hadoop./fs. client option is dropped by the rust
+        // parser and would diverge from JNI -> JNI (the canonical simple-auth
+        // case is dfs.client.use.datanode.hostname).
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "dfs.client.use.datanode.hostname", "true")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "dfs.client.socket-timeout", "60000")));
+        Assert.assertFalse(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "fs.hdfs.impl", "org.apache.hadoop.hdfs.DistributedFileSystem")));
+        // Keys outside the hadoop/dfs/fs namespaces cannot change what the
+        // rust HDFS parser does (the BE bridge passes them through untouched
+        // and the parser ignores them).
+        Assert.assertTrue(PaimonScanNode.isRustVerifiedHdfsBackend(ImmutableMap.of(
+                "warehouse", "hdfs://nn/wh", "unrelated.key", "value")));
+    }
+
+    @Test
+    public void testRustReaderSelectionRejectsProjectedVariantColumns() throws Exception {
+        // The rust leaf feeds its Arrow arrays to the slot serdes, and
+        // DataTypeVariantV2SerDe::read_column_from_arrow unconditionally returns
+        // NOT_IMPLEMENTED_ERROR — a nested Variant (ARRAY / MAP / STRUCT
+        // containing one) reaches the same decoder through the container
+        // serdes. Projections containing Variant must route to JNI; a table
+        // whose VARIANT column is not projected by this query stays on rust.
+        Type[][] shapes = new Type[][] {
+                // Plain projected columns -> rust.
+                new Type[] {Type.INT, Type.STRING},
+                // A projected VARIANT -> JNI.
+                new Type[] {Type.INT, Type.VARIANT},
+                // Nested Variant inside every container kind -> JNI.
+                new Type[] {new ArrayType(Type.VARIANT)},
+                new Type[] {new MapType(Type.STRING, Type.VARIANT)},
+                new Type[] {new StructType(new StructField("v", Type.VARIANT))},
+                // The same containers without Variant stay rust-eligible.
+                new Type[] {new ArrayType(Type.INT)},
+                new Type[] {new MapType(Type.STRING, Type.INT)}};
+        boolean[] expectRust = new boolean[] {true, false, false, false, false, true, true};
+
+        for (int i = 0; i < shapes.length; i++) {
+            TupleDescriptor desc = new TupleDescriptor(new TupleId(0));
+            for (int j = 0; j < shapes[i].length; j++) {
+                SlotDescriptor slot = new SlotDescriptor(new SlotId(j), desc);
+                slot.setType(shapes[i][j]);
+                desc.addSlot(slot);
+            }
+
+            SessionVariable vars = new SessionVariable();
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+
+            PaimonScanNode node = new PaimonScanNode(new PlanNodeId(0), desc, false, vars,
+                    ScanContext.EMPTY);
+            PaimonSource source = Mockito.mock(PaimonSource.class);
+            Mockito.when(source.getTableLocation()).thenReturn("s3://bucket/wh/db.db/t");
+            FileStoreTable paimonTable = Mockito.mock(FileStoreTable.class);
+            PaimonExternalTable externalTable = Mockito.mock(PaimonExternalTable.class);
+            if (expectRust[i]) {
+                Mockito.when(source.getExternalTable()).thenReturn(externalTable);
+                Mockito.when(paimonTable.schema()).thenReturn(new TableSchema(
+                        1, Collections.singletonList(new DataField(0, "id", new IntType())),
+                        0, Collections.emptyList(), Collections.emptyList(),
+                        Collections.emptyMap(), null));
+                Mockito.when(externalTable.getDbName()).thenReturn("db");
+                Mockito.when(externalTable.getName()).thenReturn("t");
+            }
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", paimonTable);
+            setField(PaimonScanNode.class, node, "backendStorageProperties", ImmutableMap.of(
+                    "AWS_CREDENTIALS_PROVIDER_TYPE", "DEFAULT",
+                    "AWS_ACCESS_KEY", "ak",
+                    "AWS_SECRET_KEY", "sk",
+                    "AWS_ENDPOINT", "http://127.0.0.1:19001",
+                    "AWS_REGION", "us-east-1"));
+
+            TFileRangeDesc rangeDesc = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class},
+                    rangeDesc, new PaimonSplit(createDataSplit("variant_gate.parquet")));
+            Assert.assertEquals("projection " + Arrays.toString(shapes[i]),
+                    expectRust[i] ? TPaimonReaderType.PAIMON_RUST : TPaimonReaderType.PAIMON_JNI,
+                    rangeDesc.getTableFormatParams().getPaimonParams().getReaderType());
+        }
+    }
+
+    /**
+     * Every negative assertion starts from a complete Rust-eligible fixture, so missing
+     * schema or credentials cannot accidentally satisfy a fallback test.
+     */
+    private class RustRoutingFixture {
+        final SessionVariable vars = new SessionVariable();
+        final TupleDescriptor tuple = new TupleDescriptor(new TupleId(0));
+        final PaimonSource source = Mockito.mock(PaimonSource.class);
+        final FileStoreTable table = Mockito.mock(FileStoreTable.class);
+        final org.apache.paimon.schema.SchemaManager schemas =
+                Mockito.mock(org.apache.paimon.schema.SchemaManager.class);
+        final PaimonScanNode node;
+        final DataSplit split = createDataSplit("capabilities.parquet");
+
+        RustRoutingFixture() throws Exception {
+            vars.setEnablePaimonRustReader(true);
+            vars.enableFileScannerV2 = true;
+            node = new PaimonScanNode(new PlanNodeId(0), tuple, false, vars, ScanContext.EMPTY);
+            PaimonExternalTable external = Mockito.mock(PaimonExternalTable.class);
+            Mockito.when(source.getExternalTable()).thenReturn(external);
+            Mockito.when(external.getDbName()).thenReturn("db");
+            Mockito.when(external.getName()).thenReturn("t");
+            Mockito.when(source.getTableLocation()).thenReturn("s3://warehouse/wh/db.db/t");
+            Mockito.when(table.schemaManager()).thenReturn(schemas);
+            schema(1, new IntType(), Collections.emptyMap(), false);
+            node.setSource(source);
+            setField(PaimonScanNode.class, node, "processedTable", table);
+            credentials("DEFAULT");
+            Assert.assertEquals(TPaimonReaderType.PAIMON_RUST, reader(split));
+        }
+
+        void credentials(String provider) throws Exception {
+            setField(PaimonScanNode.class, node, "backendStorageProperties", ImmutableMap.of(
+                    "AWS_CREDENTIALS_PROVIDER_TYPE", provider,
+                    "AWS_ACCESS_KEY", "example-key", "AWS_SECRET_KEY", "example-secret"));
+        }
+
+        void schema(long id, org.apache.paimon.types.DataType valueType,
+                Map<String, String> options, boolean pk) throws Exception {
+            TableSchema schema = new TableSchema(id, Arrays.asList(
+                    new DataField(0, "id", new IntType(false)), new DataField(1, "v", valueType)),
+                    10, Collections.emptyList(), pk ? Collections.singletonList("id") : Collections.emptyList(),
+                    options, null);
+            setField(PaimonScanNode.class, node, "rustReaderCapabilities", null);
+            Mockito.when(table.schema()).thenReturn(schema);
+            Mockito.when(table.coreOptions()).thenReturn(new CoreOptions(options));
+        }
+
+        TPaimonReaderType reader(DataSplit input) throws Exception {
+            TFileRangeDesc range = new TFileRangeDesc();
+            invokePrivateMethod(node, "setPaimonParams",
+                    new Class<?>[] {TFileRangeDesc.class, PaimonSplit.class}, range, new PaimonSplit(input));
+            return range.getTableFormatParams().getPaimonParams().getReaderType();
+        }
+
+        void project(Type full, Type pruned) throws Exception {
+            setField(PaimonScanNode.class, node, "rustReaderCapabilities", null);
+            SlotDescriptor slot = new SlotDescriptor(new SlotId(0), tuple);
+            slot.setColumn(new Column("v", full));
+            slot.setType(pruned);
+            tuple.addSlot(slot);
+        }
+    }
+
+    @Test
+    public void testRustPrunedNestedProjectionFallback() throws Exception {
+        StructType full = new StructType(new StructField("a", Type.INT), new StructField("b", Type.INT));
+        StructType pruned = new StructType(new StructField("b", Type.INT));
+        Type[][] shapes = {{full, pruned}, {new ArrayType(full), new ArrayType(pruned)},
+                {new MapType(Type.INT, full), new MapType(Type.INT, pruned)}};
+        for (Type[] shape : shapes) {
+            RustRoutingFixture f = new RustRoutingFixture();
+            f.project(shape[0], shape[0]);
+            Assert.assertEquals(TPaimonReaderType.PAIMON_RUST, f.reader(f.split));
+            f.tuple.getSlots().get(0).setType(shape[1]);
+            setField(PaimonScanNode.class, f.node, "rustReaderCapabilities", null);
+            Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+        }
+    }
+
+    @Test
+    public void testRustHistoricalIntegerNarrowingFallback() throws Exception {
+        for (org.apache.paimon.types.DataType target : Arrays.asList(
+                new org.apache.paimon.types.TinyIntType(), new org.apache.paimon.types.SmallIntType(),
+                new IntType())) {
+            RustRoutingFixture f = new RustRoutingFixture();
+            TableSchema old = new TableSchema(1, Arrays.asList(new DataField(0, "id", new IntType(false)),
+                    new DataField(1, "v", new org.apache.paimon.types.BigIntType())), 1,
+                    Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null);
+            Mockito.when(f.schemas.schema(1)).thenReturn(old);
+            f.schema(2, target, Collections.emptyMap(), false);
+            Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+            Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+            Mockito.verify(f.schemas, Mockito.times(1)).schema(1);
+        }
+    }
+
+    @Test
+    public void testRustStringHistoryToTemporalFallback() throws Exception {
+        for (org.apache.paimon.types.DataType sourceType : Arrays.asList(
+                DataTypes.STRING(), DataTypes.VARCHAR(20), DataTypes.CHAR(20))) {
+            for (org.apache.paimon.types.DataType target : Arrays.asList(DataTypes.DATE(),
+                    DataTypes.TIMESTAMP(0), DataTypes.TIMESTAMP(3), DataTypes.TIMESTAMP(6),
+                    DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(3))) {
+                assertHistoricalTemporalRouting(sourceType, target, TPaimonReaderType.PAIMON_JNI);
+            }
+        }
+        assertHistoricalTemporalRouting(DataTypes.DATE(), DataTypes.DATE(), TPaimonReaderType.PAIMON_RUST);
+    }
+
+    @Test
+    public void testRustIntegerHistoryToTimestampFallback() throws Exception {
+        for (int precision : new int[] {0, 3, 6}) {
+            for (org.apache.paimon.types.DataType target : Arrays.asList(DataTypes.TIMESTAMP(precision),
+                    DataTypes.TIMESTAMP_WITH_LOCAL_TIME_ZONE(precision))) {
+                for (org.apache.paimon.types.DataType sourceType : Arrays.asList(DataTypes.INT(), DataTypes.BIGINT())) {
+                    assertHistoricalTemporalRouting(sourceType, target, TPaimonReaderType.PAIMON_JNI);
+                }
+                assertHistoricalTemporalRouting(target, target, TPaimonReaderType.PAIMON_RUST);
+            }
+        }
+    }
+
+    private void assertHistoricalTemporalRouting(org.apache.paimon.types.DataType sourceType,
+            org.apache.paimon.types.DataType target, TPaimonReaderType expected) throws Exception {
+        org.apache.paimon.types.DataType[][] shapes = {
+                {sourceType, target},
+                {DataTypes.ROW(new DataField(2, "old_name", sourceType)),
+                        DataTypes.ROW(new DataField(2, "renamed", target))},
+                {DataTypes.ARRAY(sourceType), DataTypes.ARRAY(target)},
+                {DataTypes.MAP(sourceType, DataTypes.INT()), DataTypes.MAP(target, DataTypes.INT())},
+                {DataTypes.MAP(DataTypes.INT(), sourceType), DataTypes.MAP(DataTypes.INT(), target)}};
+        for (org.apache.paimon.types.DataType[] shape : shapes) {
+            RustRoutingFixture f = new RustRoutingFixture();
+            Mockito.when(f.schemas.schema(1)).thenReturn(new TableSchema(1, Arrays.asList(
+                    new DataField(0, "id", new IntType(false)), new DataField(1, "v", shape[0])),
+                    10, Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null));
+            f.schema(2, shape[1], Collections.emptyMap(), false);
+            Assert.assertEquals(Arrays.toString(shape), expected, f.reader(f.split));
+        }
+    }
+
+    @Test
+    public void testRustFloatingHistoryToDecimalFallback() throws Exception {
+        for (org.apache.paimon.types.DataType oldType : Arrays.asList(
+                DataTypes.FLOAT(), DataTypes.DOUBLE(), DataTypes.INT(), DataTypes.DECIMAL(3, 2))) {
+            org.apache.paimon.types.DataType decimal = DataTypes.DECIMAL(10, 2);
+            org.apache.paimon.types.DataType[][] shapes = {
+                    {oldType, decimal},
+                    {DataTypes.ROW(new DataField(2, "old_name", oldType)),
+                            DataTypes.ROW(new DataField(2, "renamed", decimal))},
+                    {DataTypes.ARRAY(oldType), DataTypes.ARRAY(decimal)},
+                    {DataTypes.MAP(oldType, DataTypes.INT()), DataTypes.MAP(decimal, DataTypes.INT())},
+                    {DataTypes.MAP(DataTypes.INT(), oldType), DataTypes.MAP(DataTypes.INT(), decimal)}};
+            for (org.apache.paimon.types.DataType[] shape : shapes) {
+                RustRoutingFixture f = new RustRoutingFixture();
+                Mockito.when(f.schemas.schema(1)).thenReturn(new TableSchema(1, Arrays.asList(
+                        new DataField(0, "id", new IntType(false)), new DataField(1, "v", shape[0])),
+                        10, Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null));
+                f.schema(2, shape[1], Collections.emptyMap(), false);
+                boolean floating = oldType.equals(DataTypes.FLOAT()) || oldType.equals(DataTypes.DOUBLE());
+                Assert.assertEquals(Arrays.toString(shape), floating
+                        ? TPaimonReaderType.PAIMON_JNI : TPaimonReaderType.PAIMON_RUST, f.reader(f.split));
+            }
+        }
+    }
+
+    @Test
+    public void testRustUnusedAggregateNamesFallback() throws Exception {
+        for (String engine : Arrays.asList("aggregation", "partial-update")) {
+            List<String> optionKeys = new ArrayList<>(Arrays.asList(
+                    "fields.default-aggregate-function", "fields.id.aggregate-function"));
+            if ("partial-update".equals(engine)) {
+                optionKeys.add("fields.seq.aggregate-function");
+            }
+            for (String key : optionKeys) {
+                for (String function : Arrays.asList("collect", "sum")) {
+                    RustRoutingFixture f = new RustRoutingFixture();
+                    Map<String, String> options = new HashMap<>();
+                    options.put("merge-engine", engine);
+                    options.put("fields.v.aggregate-function", "max");
+                    options.put(key, function);
+                    f.schema(1, new IntType(), options, true);
+                    if ("partial-update".equals(engine)) {
+                        options.put("fields.seq.sequence-group", "v");
+                        Mockito.when(f.table.schema()).thenReturn(new TableSchema(1, Arrays.asList(
+                                new DataField(0, "id", new IntType(false)), new DataField(1, "v", new IntType()),
+                                new DataField(2, "seq", new IntType())), 2, Collections.emptyList(),
+                                Collections.singletonList("id"), options, null));
+                    }
+                    DataFileMeta file = Mockito.spy(f.split.dataFiles().get(0));
+                    Mockito.doReturn(Optional.of(0L)).when(file).deleteRowCount();
+                    DataSplit split = DataSplit.builder().withPartition(BinaryRow.EMPTY_ROW)
+                            .withBucket(0).withBucketPath("file:///warehouse/db/t/bucket-0")
+                            .withDataFiles(Collections.singletonList(file)).build();
+                    Assert.assertEquals(engine + " " + key + "=" + function,
+                            TPaimonReaderType.PAIMON_JNI, f.reader(split));
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testRustNonIntegerHistoryToIntegerFallback() throws Exception {
+        for (org.apache.paimon.types.DataType sourceType : Arrays.asList(
+                DataTypes.FLOAT(), DataTypes.DOUBLE(), DataTypes.DECIMAL(10, 2), DataTypes.STRING())) {
+            for (org.apache.paimon.types.DataType targetType : Arrays.asList(
+                    DataTypes.TINYINT(), DataTypes.SMALLINT(), DataTypes.INT(), DataTypes.BIGINT())) {
+                org.apache.paimon.types.DataType[][] shapes = {
+                        {sourceType, targetType},
+                        {DataTypes.ROW(new DataField(2, "old_name", sourceType)),
+                                DataTypes.ROW(new DataField(2, "renamed", targetType))},
+                        {DataTypes.ARRAY(sourceType), DataTypes.ARRAY(targetType)},
+                        {DataTypes.MAP(sourceType, DataTypes.INT()), DataTypes.MAP(targetType, DataTypes.INT())},
+                        {DataTypes.MAP(DataTypes.INT(), sourceType), DataTypes.MAP(DataTypes.INT(), targetType)}};
+                for (org.apache.paimon.types.DataType[] shape : shapes) {
+                    RustRoutingFixture f = new RustRoutingFixture();
+                    Mockito.when(f.schemas.schema(1)).thenReturn(new TableSchema(1, Arrays.asList(
+                            new DataField(0, "id", new IntType(false)), new DataField(1, "v", shape[0])),
+                            10, Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null));
+                    f.schema(2, shape[1], Collections.emptyMap(), false);
+                    Assert.assertEquals(Arrays.toString(shape), TPaimonReaderType.PAIMON_JNI, f.reader(f.split));
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testRustPartialUpdateDefaultAggregateAndSequenceFields() throws Exception {
+        for (org.apache.paimon.types.DataType valueType : Arrays.asList(
+                DataTypes.TINYINT(), DataTypes.DECIMAL(2, 0), DataTypes.DOUBLE())) {
+            for (boolean override : Arrays.asList(false, true)) {
+                RustRoutingFixture f = new RustRoutingFixture();
+                Map<String, String> options = new HashMap<>();
+                options.put("merge-engine", "partial-update");
+                options.put("fields.seq,seq2.sequence-group", "v");
+                options.put("fields.default-aggregate-function", "sum");
+                if (override) {
+                    options.put("fields.v.aggregate-function", "max");
+                }
+                f.schema(1, valueType, options, true);
+                Mockito.when(f.table.schema()).thenReturn(new TableSchema(1, Arrays.asList(
+                        new DataField(0, "id", new IntType(false)), new DataField(1, "v", valueType),
+                        new DataField(2, "seq", new IntType()), new DataField(3, "seq2", new IntType())),
+                        3, Collections.emptyList(), Collections.singletonList("id"), options, null));
+                DataFileMeta file = Mockito.spy(f.split.dataFiles().get(0));
+                Mockito.doReturn(Optional.of(0L)).when(file).deleteRowCount();
+                // Legacy writers can retain an unbounded same-key run even in one file.
+                DataSplit split = DataSplit.builder().withPartition(BinaryRow.EMPTY_ROW)
+                        .withBucket(0).withBucketPath("file:///warehouse/db/t/bucket-0")
+                        .withDataFiles(Collections.singletonList(file)).build();
+                Assert.assertEquals(valueType + " override=" + override,
+                        TPaimonReaderType.PAIMON_JNI,
+                        f.reader(split));
+            }
+        }
+    }
+
+    @Test
+    public void testRustMultiFilePrimaryKeyMergeFallback() throws Exception {
+        for (String engine : Arrays.asList("deduplicate", "aggregation", "partial-update")) {
+            for (boolean primaryKey : Arrays.asList(false, true)) {
+                for (int fileCount : new int[] {1, 2, 8}) {
+                    RustRoutingFixture f = new RustRoutingFixture();
+                    f.schema(1, new IntType(), ImmutableMap.of("merge-engine", engine,
+                            "read.batch-size", "1"), primaryKey);
+                    List<DataFileMeta> files = new ArrayList<>();
+                    for (int i = 0; i < fileCount; i++) {
+                        DataFileMeta file = Mockito.spy(createDataSplit("run-" + i + ".parquet")
+                                .dataFiles().get(0));
+                        Mockito.doReturn(Optional.of(0L)).when(file).deleteRowCount();
+                        files.add(file);
+                    }
+                    DataSplit split = DataSplit.builder().withPartition(BinaryRow.EMPTY_ROW)
+                            .withBucket(0).withBucketPath("file:///warehouse/db/t/bucket-0")
+                            .withDataFiles(files).build();
+                    Assert.assertEquals(engine + " pk=" + primaryKey + " files=" + fileCount,
+                            primaryKey && (fileCount > 1 || !"deduplicate".equals(engine)) ? TPaimonReaderType.PAIMON_JNI
+                                    : TPaimonReaderType.PAIMON_RUST, f.reader(split));
+                }
+            }
+        }
+    }
+
+    @Test
+    public void testRustNestedEvolutionAndCompatibleHistory() throws Exception {
+        org.apache.paimon.types.DataType small = new org.apache.paimon.types.TinyIntType();
+        org.apache.paimon.types.DataType large = new org.apache.paimon.types.BigIntType();
+        org.apache.paimon.types.RowType oldRow = new org.apache.paimon.types.RowType(Arrays.asList(
+                new DataField(2, "a", large), new DataField(3, "b", new IntType())));
+        org.apache.paimon.types.RowType newRow = new org.apache.paimon.types.RowType(Arrays.asList(
+                new DataField(3, "b", new IntType()), new DataField(2, "renamed", small)));
+        org.apache.paimon.types.DataType[][] shapes = {
+                {large, small}, {oldRow, newRow},
+                {new org.apache.paimon.types.ArrayType(oldRow), new org.apache.paimon.types.ArrayType(newRow)},
+                {new org.apache.paimon.types.MapType(large, new IntType()),
+                        new org.apache.paimon.types.MapType(small, new IntType())},
+                {new org.apache.paimon.types.MapType(new IntType(), oldRow),
+                        new org.apache.paimon.types.MapType(new IntType(), newRow)}};
+        for (org.apache.paimon.types.DataType[] shape : shapes) {
+            for (boolean narrow : Arrays.asList(true, false)) {
+                RustRoutingFixture f = new RustRoutingFixture();
+                TableSchema old = new TableSchema(1, Arrays.asList(new DataField(0, "id", new IntType(false)),
+                        new DataField(1, "v", shape[narrow ? 0 : 1])), 10,
+                        Collections.emptyList(), Collections.emptyList(), Collections.emptyMap(), null);
+                Mockito.when(f.schemas.schema(1)).thenReturn(old);
+                f.schema(2, shape[narrow ? 1 : 0], Collections.emptyMap(), false);
+                Assert.assertEquals(narrow ? TPaimonReaderType.PAIMON_JNI : TPaimonReaderType.PAIMON_RUST,
+                        f.reader(f.split));
+            }
+        }
+        RustRoutingFixture missing = new RustRoutingFixture();
+        missing.schema(2, new IntType(), Collections.emptyMap(), false);
+        Mockito.when(missing.schemas.schema(1)).thenThrow(new IllegalStateException("missing schema"));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, missing.reader(missing.split));
+        Assert.assertEquals(TPaimonReaderType.PAIMON_JNI, missing.reader(missing.split));
+        Mockito.verify(missing.schemas, Mockito.times(1)).schema(1);
+    }
+
+    @Test
+    public void testRustDefaultAggregateAndFieldOverride() throws Exception {
+        for (Map<String, String> options : Arrays.asList(
+                ImmutableMap.of("merge-engine", "aggregation", "fields.default-aggregate-function", "sum"),
+                ImmutableMap.of("merge-engine", "aggregation", "fields.default-aggregate-function", "max"),
+                ImmutableMap.of("merge-engine", "aggregation", "fields.default-aggregate-function", "sum",
+                        "fields.v.aggregate-function", "max"))) {
+            RustRoutingFixture f = new RustRoutingFixture();
+            f.schema(1, new IntType(), options, true);
+            DataFileMeta file = Mockito.spy(f.split.dataFiles().get(0));
+            Mockito.doReturn(Optional.of(0L)).when(file).deleteRowCount();
+            DataSplit split = DataSplit.builder().withPartition(BinaryRow.EMPTY_ROW)
+                    .withBucket(0).withBucketPath("file:///warehouse/db/t/bucket-0")
+                    .withDataFiles(Collections.singletonList(file)).build();
+            Assert.assertEquals(TPaimonReaderType.PAIMON_JNI,
+                    f.reader(split));
+        }
+    }
+
+    @Test
+    public void testRustPrimaryKeyRetractsFallback() throws Exception {
+        for (String engine : Arrays.asList("deduplicate", "aggregation", "partial-update")) {
+            for (Long deletes : Arrays.asList(0L, 1L, null)) {
+                RustRoutingFixture f = new RustRoutingFixture();
+                f.schema(1, new IntType(), ImmutableMap.of("merge-engine", engine), true);
+                DataFileMeta file = Mockito.spy(f.split.dataFiles().get(0));
+                Mockito.doReturn(Optional.ofNullable(deletes)).when(file).deleteRowCount();
+                DataSplit split = DataSplit.builder().withPartition(BinaryRow.EMPTY_ROW)
+                        .withBucket(0).withBucketPath("file:///warehouse/db/t/bucket-0")
+                        .withDataFiles(Collections.singletonList(file)).build();
+                Assert.assertEquals(engine + " deletes=" + deletes,
+                        "deduplicate".equals(engine) && deletes != null && deletes == 0L ? TPaimonReaderType.PAIMON_RUST
+                                : TPaimonReaderType.PAIMON_JNI, f.reader(split));
+            }
+        }
+    }
+
+    @Test
+    public void testRustAggregateFunctionAndArithmeticFallback() throws Exception {
+        Object[][] cases = {
+                {"collect", new org.apache.paimon.types.ArrayType(new IntType()), false},
+                {"sum", new org.apache.paimon.types.TinyIntType(), false},
+                {"sum", new org.apache.paimon.types.SmallIntType(), false},
+                {"sum", new IntType(), false},
+                {"sum", new org.apache.paimon.types.BigIntType(), false},
+                {"product", new IntType(), false},
+                {"sum", new org.apache.paimon.types.DecimalType(2, 0), false},
+                {"sum", new org.apache.paimon.types.DecimalType(18, 2), false},
+                {"sum", new org.apache.paimon.types.DecimalType(19, 2), true},
+                {"product", new org.apache.paimon.types.DecimalType(20, 2), false},
+                {"sum", new org.apache.paimon.types.DoubleType(), true},
+                {"max", new IntType(), true},
+                {"max", new org.apache.paimon.types.ArrayType(new IntType()), false},
+                {"bool_and", new org.apache.paimon.types.BooleanType(), true},
+                {"last_non_null_value", new org.apache.paimon.types.ArrayType(new IntType()), true}
+        };
+        for (String engine : Arrays.asList("aggregation", "partial-update")) {
+            for (Object[] shape : cases) {
+                RustRoutingFixture f = new RustRoutingFixture();
+                f.schema(1, (org.apache.paimon.types.DataType) shape[1], ImmutableMap.of(
+                        "merge-engine", engine, "fields.v.aggregate-function", (String) shape[0]), true);
+                DataFileMeta file = Mockito.spy(f.split.dataFiles().get(0));
+                Mockito.doReturn(Optional.of(0L)).when(file).deleteRowCount();
+                DataSplit split = DataSplit.builder().withPartition(BinaryRow.EMPTY_ROW)
+                        .withBucket(0).withBucketPath("file:///warehouse/db/t/bucket-0")
+                        .withDataFiles(Collections.singletonList(file)).build();
+                Assert.assertEquals(engine + " " + Arrays.toString(shape),
+                        TPaimonReaderType.PAIMON_JNI, f.reader(split));
+            }
+        }
+    }
+
+
 }
diff --git a/fe/fe-core/src/test/java/org/apache/doris/qe/runtime/PipelineExecutionTaskBuilderTest.java b/fe/fe-core/src/test/java/org/apache/doris/qe/runtime/PipelineExecutionTaskBuilderTest.java
new file mode 100644
index 00000000000..994853a04cd
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/qe/runtime/PipelineExecutionTaskBuilderTest.java
@@ -0,0 +1,96 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package org.apache.doris.qe.runtime;
+
+import org.apache.doris.common.profile.ExecutionProfile;
+import org.apache.doris.system.Backend;
+import org.apache.doris.thrift.TDetailedReportParams;
+import org.apache.doris.thrift.TPipelineFragmentParams;
+import org.apache.doris.thrift.TPipelineFragmentParamsList;
+import org.apache.doris.thrift.TPipelineInstanceParams;
+import org.apache.doris.thrift.TQueryProfile;
+import org.apache.doris.thrift.TRuntimeProfileNode;
+import org.apache.doris.thrift.TRuntimeProfileTree;
+import org.apache.doris.thrift.TUniqueId;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Map;
+
+public class PipelineExecutionTaskBuilderTest {
+    @Test
+    public void buildSingleFragmentPipelineTaskRegistersBackendWithExecutionProfile() {
+        TUniqueId queryId = new TUniqueId(1, 2);
+        ExecutionProfile executionProfile = new ExecutionProfile(queryId, Lists.newArrayList(0));
+        Backend backend = new Backend(1L, "127.0.0.1", 9050);
+
+        Map<Integer, SingleFragmentPipelineTask> tasks =
+                PipelineExecutionTaskBuilder.buildSingleFragmentPipelineTask(
+                        executionProfile, backend, createFragmentParamsList(0));
+
+        Assert.assertEquals(1, tasks.size());
+        Assert.assertTrue(tasks.containsKey(0));
+
+        executionProfile.updateProfile(createQueryProfile(queryId, 0), backend.getHeartbeatAddress(), true);
+        Assert.assertTrue(executionProfile.isCompleted());
+    }
+
+    private static TPipelineFragmentParamsList createFragmentParamsList(int fragmentId) {
+        TPipelineInstanceParams instanceParams = new TPipelineInstanceParams();
+        instanceParams.setFragmentInstanceId(new TUniqueId(3, 4));
+
+        TPipelineFragmentParams fragmentParams = new TPipelineFragmentParams();
+        fragmentParams.setFragmentId(fragmentId);
+        fragmentParams.setLocalParams(Lists.newArrayList(instanceParams));
+
+        TPipelineFragmentParamsList paramsList = new TPipelineFragmentParamsList();
+        paramsList.setParamsList(Lists.newArrayList(fragmentParams));
+        return paramsList;
+    }
+
+    private static TQueryProfile createQueryProfile(TUniqueId queryId, int fragmentId) {
+        TQueryProfile queryProfile = new TQueryProfile();
+        queryProfile.setQueryId(queryId);
+        queryProfile.putToFragmentIdToProfile(fragmentId, Lists.newArrayList(createReportParam()));
+        return queryProfile;
+    }
+
+    private static TDetailedReportParams createReportParam() {
+        TRuntimeProfileNode node = new TRuntimeProfileNode();
+        node.setName("PipelineProfile");
+        node.setNumChildren(0);
+        node.setCounters(Lists.newArrayList());
+        node.setMetadata(0);
+        node.setIndent(false);
+        node.setInfoStrings(Maps.newHashMap());
+        node.setInfoStringsDisplayOrder(Lists.newArrayList());
+        node.setChildCountersMap(Maps.newHashMap());
+        node.setTimestamp(0);
+
+        TRuntimeProfileTree tree = new TRuntimeProfileTree();
+        tree.setNodes(Lists.newArrayList(node));
+
+        TDetailedReportParams params = new TDetailedReportParams();
+        params.setProfile(tree);
+        params.setIsFragmentLevel(false);
+        return params;
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/system/BackendPaimonRustCapabilityTest.java b/fe/fe-core/src/test/java/org/apache/doris/system/BackendPaimonRustCapabilityTest.java
new file mode 100644
index 00000000000..9b085b3c7e4
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/system/BackendPaimonRustCapabilityTest.java
@@ -0,0 +1,70 @@
+// 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.system;
+
+import org.apache.doris.persist.gson.GsonUtils;
+import org.apache.doris.resource.Tag;
+import org.apache.doris.thrift.TBackendInfo;
+
+import com.google.gson.JsonObject;
+import org.junit.Assert;
+import org.junit.Test;
+
+public class BackendPaimonRustCapabilityTest {
+    @Test
+    public void testCapabilitySurvivesHeartbeatReplayAndBackendImage() {
+        Backend backend = new Backend(1, "127.0.0.1", 9050);
+        Assert.assertFalse(backend.isPaimonRustReaderSupported());
+        BackendHbResponse response = heartbeat();
+        response.setPaimonRustReaderSupported(true);
+        BackendHbResponse replay = GsonUtils.GSON.fromJson(GsonUtils.GSON.toJson(response), BackendHbResponse.class);
+        Assert.assertTrue(replay.isPaimonRustReaderSupported());
+        Assert.assertTrue(backend.handleHbResponse(replay, true));
+        Assert.assertTrue(backend.isPaimonRustReaderSupported());
+        Backend restored = GsonUtils.GSON.fromJson(GsonUtils.GSON.toJson(backend), Backend.class);
+        Assert.assertTrue(restored.isPaimonRustReaderSupported());
+    }
+
+    @Test
+    public void testLegacyHeartbeatClearsPreviouslyAdvertisedSupport() {
+        Backend backend = new Backend(1, "127.0.0.1", 9050);
+        BackendHbResponse response = heartbeat();
+        response.setPaimonRustReaderSupported(true);
+        backend.handleHbResponse(response, false);
+        Assert.assertTrue(backend.isPaimonRustReaderSupported());
+
+        TBackendInfo legacy = new TBackendInfo();
+        Assert.assertFalse(legacy.isSetSupportsPaimonRustReader());
+        Assert.assertFalse(legacy.isSupportsPaimonRustReader());
+        BackendHbResponse legacyResponse = heartbeat();
+        Assert.assertFalse(legacyResponse.isPaimonRustReaderSupported());
+        Assert.assertTrue(backend.handleHbResponse(legacyResponse, false));
+        Assert.assertFalse(backend.isPaimonRustReaderSupported());
+
+        JsonObject legacyJson = GsonUtils.GSON.toJsonTree(heartbeat()).getAsJsonObject();
+        legacyJson.remove("supportsPaimonRustReader");
+        BackendHbResponse replay = GsonUtils.GSON.fromJson(legacyJson, BackendHbResponse.class);
+        Assert.assertFalse(replay.isPaimonRustReaderSupported());
+        Assert.assertFalse(GsonUtils.GSON.fromJson("{}", Backend.class).isPaimonRustReaderSupported());
+    }
+
+    private BackendHbResponse heartbeat() {
+        return new BackendHbResponse(1, 9060, 8040, 8060, 1000, 1000, "test-version", Tag.VALUE_MIX,
+                0, 0, false, 8070);
+    }
+}
diff --git a/gensrc/thrift/HeartbeatService.thrift b/gensrc/thrift/HeartbeatService.thrift
index 3fde7fdf2df..8608853b29f 100644
--- a/gensrc/thrift/HeartbeatService.thrift
+++ b/gensrc/thrift/HeartbeatService.thrift
@@ -63,6 +63,7 @@ struct TBackendInfo {
     8: optional bool is_shutdown
     9: optional Types.TPort arrow_flight_sql_port
     10: optional i64 be_mem // The physical memory available for use by BE.
+    11: optional bool supports_paimon_rust_reader
     // For cloud
     1000: optional i64 fragment_executing_count
     1001: optional i64 fragment_last_active_time
diff --git a/gensrc/thrift/PaloInternalService.thrift b/gensrc/thrift/PaloInternalService.thrift
index 5aa105c3608..2044f058052 100644
--- a/gensrc/thrift/PaloInternalService.thrift
+++ b/gensrc/thrift/PaloInternalService.thrift
@@ -510,6 +510,9 @@ struct TQueryOptions {
 
   // Fall back to RE2 when Hyperscan cannot compile a regular expression.
   228: optional bool enable_hyperscan_fallback = true;
+  // Use paimon-rust to read Paimon splits on BE. Higher priority than
+  // enable_paimon_cpp_reader.
+  229: optional bool enable_paimon_rust_reader = false;
 
   // Master uses 226 for this option; branch-4.2 already uses 226-228, so the id was moved.
   229: optional bool enable_prune_nested_column = false;
diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift
index aaf057b6327..3e626e6f0ad 100644
--- a/gensrc/thrift/PlanNodes.thrift
+++ b/gensrc/thrift/PlanNodes.thrift
@@ -357,6 +357,7 @@ enum TPaimonReaderType {
     PAIMON_JNI = 1,
     // Deprecated wire value kept during rolling upgrades. New plans never emit it.
     PAIMON_CPP = 2,
+    PAIMON_RUST = 3,
 }
 
 struct TPaimonFileDesc {
@@ -378,6 +379,12 @@ struct TPaimonFileDesc {
     16: optional i64 schema_id; // for schema change.
     // Reader implementation for logical paimon split. Native file split uses range format type.
     17: optional TPaimonReaderType reader_type;
+    // paimon-rust: TableSchema serialized via JsonSerdeUtil.toJson(table.schema()).
+    // Consumed by paimon_table_from_schema_json on BE; skips catalog/warehouse resolution.
+    18: optional string paimon_table_schema_json
+    // paimon-rust: non-default branch name. Unset means main branch (matches
+    // upstream paimon commit 742da63: null-if-DEFAULT_MAIN_BRANCH).
+    19: optional string paimon_branch
 }
 
 struct TTrinoConnectorFileDesc {
diff --git a/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_cast_predicates.groovy b/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_cast_predicates.groovy
new file mode 100644
index 00000000000..5daac9772fa
--- /dev/null
+++ b/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_cast_predicates.groovy
@@ -0,0 +1,192 @@
+// 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.
+
+// CASTed operands through the paimon rust reader. The rust predicate converter
+// must NOT strip a cast from a column before pushing: for a DECIMAL(10,2)
+// column holding 1.24, `CAST(amount AS DECIMAL(10,1)) = 1.2` must retain the
+// row, while the unwrapped `amount = 1.2` prunes it — and rows pruned by the
+// pushed filter cannot be recovered by the Doris residual. The FE converter
+// already rejects casted operands (PaimonPredicateConverterTest's
+// rejectDecimalScaleCast / rejectStringToIntegerCast), so JNI never pushes
+// them; the rust reader must match. Cast-induced NULL semantics
+// (`CAST(s AS INT) IS NULL`) are covered the same way.
+//
+// Both differential legs run with force_jni_scanner=true: these parquet
+// append tables convert to raw native splits, which getSplits() would
+// otherwise prefer — both legs would silently use the native reader and
+// never reach the JNI / rust converters. The actual reader path is verified
+// per leg through the query profile (the rust reader's PaimonRustReader
+// timer group).
+import org.apache.doris.regression.action.ProfileAction
+
+suite("test_paimon_rust_reader_cast_predicates", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disabled paimon test")
+        return
+    }
+
+    String catalogName = "test_paimon_rust_cast"
+    String dbName = "test_paimon_rust_cast_db"
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+
+    // Table is created via Spark because Doris does not support Paimon DDL.
+    // t_decimal_cast mirrors the FE rejectDecimalScaleCast shape: a
+    // DECIMAL(10,2) column whose scale-reducing cast must not be pushed.
+    // t_cast_null covers cast-related NULL semantics on a string column.
+    spark_paimon_multi """
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+        DROP TABLE IF EXISTS paimon.${dbName}.t_decimal_cast;
+        CREATE TABLE paimon.${dbName}.t_decimal_cast (
+            id INT, amount DECIMAL(10, 2)
+        ) USING paimon;
+        INSERT INTO paimon.${dbName}.t_decimal_cast VALUES
+            (1, 1.24), (2, 1.20), (3, 1.30), (4, NULL);
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_cast_null;
+        CREATE TABLE paimon.${dbName}.t_cast_null (
+            id INT, s VARCHAR(10)
+        ) USING paimon;
+        INSERT INTO paimon.${dbName}.t_cast_null VALUES (1, '5'), (2, NULL);
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_partition_constants;
+        CREATE TABLE paimon.${dbName}.t_partition_constants (id INT, p VARCHAR(3))
+            USING paimon PARTITIONED BY (p) TBLPROPERTIES ('bucket'='-1');
+        INSERT INTO paimon.${dbName}.t_partition_constants VALUES (1,'abc'), (2,'abc'), (3,NULL);
+    """
+
+    // The s3.region property is required: paimon-rust's S3 client rejects a
+    // missing region, while the JNI reader falls back to the SDK default.
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.region' = 'us-east-1',
+            'use_path_style' = 'true'
+        );
+    """
+
+    // Capture the settings this suite overrides so finally can restore them.
+    def originalForceJni = sql("select @@force_jni_scanner")[0][0]
+    def originalEnableProfile = sql("select @@enable_profile")[0][0]
+    def originalTruncate = sql("select @@truncate_char_or_varchar_columns")[0][0]
+
+    try {
+        sql """switch ${catalogName}"""
+        sql """use ${dbName}"""
+        sql """set enable_file_scanner_v2=true"""
+        // These are parquet append tables whose DataSplits convert to raw
+        // native splits; force the logical (JNI / rust) reader path so the
+        // differential actually exercises both converters.
+        sql """set force_jni_scanner=true"""
+        sql """set enable_profile=true"""
+        sql "set truncate_char_or_varchar_columns=true"
+
+        // Use configured HTTP credentials and wait for the Rust group as well as profile
+        // completion: the scanner group can appear before its child reader timers arrive.
+        def profiles = new ProfileAction(context)
+        def profileTextOf = { String query, boolean rustExpected ->
+            sql(query)
+            def queryId = sql("select last_query_id()")[0][0].toString()
+            def required = rustExpected ? ["FileScannerV2", "PaimonRustReader"] : ["FileScannerV2"]
+            profiles.getProfile(queryId, required)
+        }
+
+        def testQueries = [
+                // Scale-reducing cast: 1.24 rounds to 1.2 under the cast, so
+                // the 1.24 row (id 1) must be retained — exactly the row a
+                // wrongly pushed `amount = 1.2` would drop forever.
+                """select id from t_decimal_cast where cast(amount as decimal(10,1)) = 1.2 order by id""",
+                """select id from t_decimal_cast where cast(amount as decimal(10,1)) = 1.3 order by id""",
+                // Plain (uncast) decimal equality still pushes and matches.
+                """select id from t_decimal_cast where amount = 1.24 order by id""",
+                // CAST IS NULL / IS NOT NULL on the casted column keep NULL
+                // semantics: only the NULL row (id 4) is null under the cast.
+                """select id from t_decimal_cast where cast(amount as decimal(10,1)) is null order by id""",
+                """select id from t_decimal_cast where cast(amount as decimal(10,1)) is not null order by id""",
+                // Cast-induced NULL: CAST('5' AS INT) is 5, not NULL.
+                """select id from t_cast_null where cast(s as int) is null order by id""",
+                """select id from t_cast_null where cast(s as int) = 5 order by id""",
+                // IN / NOT IN with a casted list value. FE folds the
+                // list-value cast at plan time (even with
+                // debug_skip_fold_constant) and aligns the column with a
+                // column-side cast instead, so both readers correctly keep
+                // everything in the residual here. The un-folded shape — the
+                // cast still inside the IN list — is covered by the
+                // PaimonRustPredicateConverterTest unit tests, where the
+                // converter rejects any casted IN-list child (FE's
+                // doInPredicate parity).
+                """select id from t_decimal_cast where amount in (cast(1.24 as decimal(10,1)), 1.30) order by id""",
+                """select id from t_decimal_cast where amount not in (cast(1.24 as decimal(10,1))) order by id"""
+        ]
+        def expectedResults = [
+                [[1], [2]],
+                [[3]],
+                [[1]],
+                [[4]],
+                [[1], [2], [3]],
+                [[2]],
+                [[1]],
+                [[2], [3]],
+                [[1], [3]]
+        ]
+        // Partition keys are supplied as nullable constants outside the Arrow batch.
+        testQueries.add("select id,p from t_partition_constants order by id")
+        expectedResults.add([[1,"abc"],[2,"abc"],[3,null]])
+        // Representative scale-cast query reused for the reader-path checks.
+        String scaleCastQuery = testQueries[0]
+
+        sql """set enable_paimon_rust_reader=false"""
+        def jniResults = testQueries.collect { query -> sql(query) }
+        // The JNI leg must ride the logical-split JNI reader: the profile of
+        // the representative query must not contain the rust reader's timer.
+        def jniProfile = profileTextOf(scaleCastQuery, false)
+        assertFalse(jniProfile.contains("PaimonRustReader"), "JNI leg must not use the rust reader")
+
+        sql """set enable_paimon_rust_reader=true"""
+        def rustResults = testQueries.collect { query -> sql(query) }
+        // The rust leg must actually run the rust reader: its profile carries
+        // the PaimonRustReader timer group, which only the rust reader creates.
+        def rustProfile = profileTextOf(scaleCastQuery, true)
+        assertTrue(rustProfile.contains("PaimonRustReader"),
+                "rust leg must use the rust reader (profile timer missing)")
+
+        assertTrue(profileTextOf(testQueries.last(), true).contains("PaimonRustReader"),
+                "partition constants must exercise the Rust materialization path")
+        for (int i = 0; i < testQueries.size(); i++) {
+            // The rust reader must agree with the JNI reader (which, like FE,
+            // never pushes a casted operand) on every form.
+            assertEquals(jniResults[i].toString(), rustResults[i].toString())
+            // And both must be right, not just mutually consistent: id 1 in
+            // the first query is exactly the row a wrongly pushed
+            // `amount = 1.2` would drop.
+            assertEquals(expectedResults[i].toString(), rustResults[i].toString())
+        }
+    } finally {
+        sql """set enable_paimon_rust_reader=false"""
+        sql """set force_jni_scanner=${originalForceJni}"""
+        sql """set enable_profile=${originalEnableProfile}"""
+        sql "set truncate_char_or_varchar_columns=${originalTruncate}"
+        sql """drop catalog if exists ${catalogName}"""
+    }
+}
diff --git a/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_compatibility.groovy b/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_compatibility.groovy
new file mode 100644
index 00000000000..cbfaa54525b
--- /dev/null
+++ b/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_compatibility.groovy
@@ -0,0 +1,308 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.apache.doris.regression.action.ProfileAction
+
+suite("test_paimon_rust_reader_compatibility", "p0,external,paimon") {
+    if (!"true".equalsIgnoreCase(context.config.otherConfigs.get("enablePaimonTest"))) {
+        return
+    }
+    def endpoint = context.config.otherConfigs.get("externalEnvIp")
+    def port = context.config.otherConfigs.get("iceberg_minio_port")
+    def catalog = "test_paimon_rust_compatibility"
+    def database = "test_paimon_rust_compatibility_db"
+    def settings = ["enable_paimon_rust_reader", "force_jni_scanner",
+                    "enable_file_scanner_v2", "enable_profile", "enable_prune_nested_column",
+                    "enable_push_down_no_group_agg", "time_zone"]
+    def saved = settings.collectEntries { [(it): sql("select @@${it}")[0][0]] }
+    sql "DROP CATALOG IF EXISTS ${catalog}"
+    sql """CREATE CATALOG ${catalog} PROPERTIES (
+        'type'='paimon', 'paimon.catalog.type'='filesystem', 'fs.s3.support'='true',
+        'warehouse'='s3://warehouse/wh', 's3.endpoint'='http://${endpoint}:${port}',
+        's3.access_key'='admin', 's3.secret_key'='password',
+        's3.region'='us-east-1', 'use_path_style'='true')"""
+    sql "SWITCH ${catalog}"
+    sql "DROP DATABASE IF EXISTS ${database} FORCE"
+    sql "CREATE DATABASE ${database}"
+    sql "USE ${database}"
+    try {
+        sql "set enable_profile=true"
+        sql "set time_zone='+00:00'"
+        sql "set enable_prune_nested_column=true"
+        sql "set enable_file_scanner_v2=true"
+        sql "set force_jni_scanner=true"
+        def profiles = new ProfileAction(context)
+        def check = { String query, List expected, boolean rustExpected ->
+            sql "set enable_paimon_rust_reader=false"
+            def baseline = sql(query)
+            assertEquals(expected.toString(), baseline.toString())
+            sql "set enable_paimon_rust_reader=true"
+            def actual = sql(query)
+            def queryId = sql("select last_query_id()")[0][0].toString()
+            // A scanner can be published before its children; absence is meaningful only at completion.
+            def required = ["FileScannerV2", "Profile Completion State: COMPLETE"]
+            if (rustExpected) {
+                required.add("PaimonRustReader")
+            }
+            def profile = profiles.getProfile(queryId, required)
+            assertEquals(expected.toString(), actual.toString())
+            assertEquals(rustExpected, profile.contains("PaimonRustReader"), query)
+        }
+        def createPk = { String name, String valueColumns, String extra ->
+            // Separate commits must remain separate files so the reader performs the merge.
+            sql """CREATE TABLE ${name} (id INT NOT NULL, ${valueColumns}) ENGINE=paimon
+                PROPERTIES ('primary-key'='id', 'bucket'='1', 'write-only'='true',
+                    'deletion-vectors.enabled'='false' ${extra})"""
+        }
+
+        createPk("credential_values", "v INT", "")
+        sql "INSERT INTO credential_values VALUES (1,11)"
+        check("select id,v from credential_values", [[1,11]], true)
+        ["'s3.credentials_provider_type'='ANONYMOUS'",
+         "'s3.role_arn'='arn:aws:iam::123456789012:role/example'",
+         "'s3.session_token'='expired-example-token'"].eachWithIndex { extra, index ->
+            def crossedCatalog = "${catalog}_credentials_${index}"
+            sql "DROP CATALOG IF EXISTS ${crossedCatalog}"
+            try {
+                // Select S3 explicitly: endpoint guessing would choose MinIO and ignore S3 auth modes.
+                // Hadoop chooses static keys first; conflicting settings must retain that identity.
+                sql """CREATE CATALOG ${crossedCatalog} PROPERTIES (
+                    'type'='paimon', 'paimon.catalog.type'='filesystem', 'fs.s3.support'='true',
+                    'warehouse'='s3://warehouse/wh', 's3.endpoint'='http://${endpoint}:${port}',
+                    's3.access_key'='admin', 's3.secret_key'='password',
+                    's3.region'='us-east-1', 'use_path_style'='true', ${extra})"""
+                check("select id,v from ${crossedCatalog}.${database}.credential_values", [[1,11]], false)
+            } finally {
+                sql "DROP CATALOG IF EXISTS ${crossedCatalog}"
+            }
+        }
+
+        createPk("branch_scan_mode", "v INT", ", 'scan.mode'='latest-full'")
+        sql "INSERT INTO branch_scan_mode VALUES (1,11)"
+        spark_paimon """CALL paimon.sys.create_tag(table => '${database}.branch_scan_mode', tag => 'audit_tag')"""
+        spark_paimon """CALL paimon.sys.create_branch(table => '${database}.branch_scan_mode',
+            branch => 'audit', tag => 'audit_tag')"""
+        // A branch preserves the persisted scan mode instead of deriving one from a snapshot selector.
+        check("select id,v from branch_scan_mode@branch(audit)", [[1,11]], false)
+
+        sql """CREATE TABLE footer_aggregates (v INT NULL) ENGINE=paimon
+            PROPERTIES ('bucket'='-1', 'file.format'='parquet')"""
+        sql "INSERT INTO footer_aggregates VALUES (NULL), (-3), (7), (NULL), (2), (5)"
+        def aggregateQueries = [
+            ["select count(v) from footer_aggregates", [[4]], "COUNT"],
+            ["select min(v), max(v) from footer_aggregates", [[-3,7]], "MINMAX"]]
+        def rawRows = { String profile ->
+            def values = (profile =~ /RawRowsRead: ([0-9]+)/).collect { it[1] as long }
+            assertFalse(values.isEmpty(), "missing Parquet RawRowsRead counter")
+            values
+        }
+        // Rust only replaces logical JNI splits. Native raw-file splits must retain footer
+        // aggregation with Rust enabled; result equality alone would hide a full-scan regression.
+        sql "set force_jni_scanner=false"
+        aggregateQueries.each { entry ->
+            [false, true].each { rustEnabled ->
+                sql "set enable_paimon_rust_reader=${rustEnabled}"
+                [false, true].each { pushdown ->
+                    sql "set enable_push_down_no_group_agg=${pushdown}"
+                    explain {
+                        sql(entry[0])
+                        contains "pushdown agg=${pushdown ? entry[2] : 'NONE'}"
+                        contains "paimonNativeReadSplits="
+                    }
+                    assertEquals(entry[1].toString(), sql(entry[0]).toString())
+                    def queryId = sql("select last_query_id()")[0][0].toString()
+                    def profile = profiles.getProfile(queryId, ["FileScannerV2", "ParquetReader", "Profile Completion State: COMPLETE"])
+                    assertFalse(profile.contains("PaimonRustReader"))
+                    def rows = rawRows(profile)
+                    if (pushdown) {
+                        assertTrue(rows.every { it == 0 }, "footer aggregate must not read data rows")
+                    } else {
+                        assertTrue(rows.any { it > 0 }, "full-scan control must read data rows")
+                    }
+                }
+            }
+        }
+        sql "set enable_push_down_no_group_agg=true"
+        sql "set force_jni_scanner=true"
+        // Forced logical splits still return real rows to the upper aggregate in both readers.
+        aggregateQueries.each { entry -> check(entry[0], entry[1], true) }
+
+        ["DATE", "DATETIME(3)"].eachWithIndex { target, index ->
+            def name = "string_to_temporal_${index}"
+            sql """CREATE TABLE ${name} (id INT, v STRING) ENGINE=paimon
+                PROPERTIES ('bucket'='-1', 'file.format'='parquet')"""
+            sql "INSERT INTO ${name} VALUES (1,'42'), (2,NULL)"
+            check("select id,v from ${name} order by id", [[1,"42"],[2,null]], true)
+            sql "ALTER TABLE ${name} MODIFY COLUMN v ${target} NULL"
+            // The historical string is an epoch count in Java, not an Arrow calendar literal.
+            check("select id,cast(v as string) from ${name} order by id",
+                    [[1,index == 0 ? "1970-02-12" : "1970-01-01 00:00:00.042"],[2,null]], false)
+        }
+        ["INT", "BIGINT"].each { sourceType ->
+            [0, 3, 6].each { precision ->
+                def name = "integer_to_timestamp_${sourceType.toLowerCase()}_${precision}"
+                sql """CREATE TABLE ${name} (id INT, v ${sourceType}) ENGINE=paimon
+                    PROPERTIES ('bucket'='-1', 'file.format'='parquet')"""
+                sql "INSERT INTO ${name} VALUES (1,1700000000), (2,NULL)"
+                sql "ALTER TABLE ${name} MODIFY COLUMN v DATETIME(${precision}) NULL"
+                // All target precisions must interpret the source number as epoch seconds.
+                check("select id,date_format(v,'%Y-%m-%d %H:%i:%s') from ${name} order by id",
+                        [[1,"2023-11-14 22:13:20"],[2,null]], false)
+            }
+        }
+
+        createPk("nested_values", "v STRUCT<a:INT,b:INT>, arr ARRAY<STRUCT<a:INT,b:INT>>, "
+                + "m MAP<INT,STRUCT<a:INT,b:INT>>", "")
+        // Bracket array literals accept constants only; nested expressions need the array constructor.
+        sql """INSERT INTO nested_values VALUES (1, named_struct('a',11,'b',22),
+            array(named_struct('a',33,'b',44)), map(1,named_struct('a',55,'b',66)))"""
+        check("select id from nested_values", [[1]], true)
+        // Distinct siblings expose ordinal decoding of a pruned second child.
+        check("select struct_element(v,'b') from nested_values", [[22]], false)
+        check("select struct_element(arr[1],'b') from nested_values", [[44]], false)
+        check("select struct_element(m[1],'b') from nested_values", [[66]], false)
+
+        createPk("narrow_values", "v BIGINT NULL", "")
+        sql "INSERT INTO narrow_values VALUES (1,383), (2,-129), (3,NULL)"
+        check("select id,v from narrow_values order by id", [[1,383],[2,-129],[3,null]], true)
+        sql "ALTER TABLE narrow_values MODIFY COLUMN v TINYINT NULL"
+        check("select id,v from narrow_values order by id", [[1,127],[2,127],[3,null]], false)
+        // A narrowing predicate must also be evaluated after the Java-compatible cast.
+        check("select id,v from narrow_values where v=127 order by id", [[1,127],[2,127]], false)
+
+        def integerCases = [
+            ["TINYINT", "127", "1", "-128", "64", "2", "-128"],
+            ["SMALLINT", "32767", "1", "-32768", "16384", "2", "-32768"],
+            ["INT", "2147483647", "1", "-2147483648", "1073741824", "2", "-2147483648"],
+            ["BIGINT", "9223372036854775807", "1", "-9223372036854775808",
+                "4611686018427387904", "2", "-9223372036854775808"]]
+        integerCases.eachWithIndex { c, index ->
+            ["sum", "product"].each { function ->
+                def name = "integer_${function}_${index}"
+                int offset = function == "sum" ? 1 : 4
+                createPk(name, "v ${c[0]}", ", 'merge-engine'='aggregation', "
+                        + "'fields.v.aggregate-function'='${function}'")
+                sql "INSERT INTO ${name} VALUES (1,${c[offset]})"
+                sql "INSERT INTO ${name} VALUES (1,${c[offset+1]})"
+                check("select cast(v as string) from ${name}", [[c[offset+2]]], false)
+            }
+        }
+        createPk("decimal_sum", "v DECIMAL(2,0)", ", 'merge-engine'='aggregation', "
+                + "'fields.v.aggregate-function'='sum'")
+        [99, 1, -1].each { sql "INSERT INTO decimal_sum VALUES (1,${it})" }
+        check("select cast(v as string) from decimal_sum", [["99"]], false)
+
+        createPk("collected_values", "v ARRAY<INT>", ", 'merge-engine'='aggregation', "
+                + "'fields.v.aggregate-function'='collect'")
+        sql "INSERT INTO collected_values VALUES (1,[11])"
+        sql "INSERT INTO collected_values VALUES (1,[22])"
+        check("select element_at(array_sort(v),1), element_at(array_sort(v),2) from collected_values",
+                [[11,22]], false)
+
+        // Safe single-file merge engines remain positive controls for the Rust route.
+        ["deduplicate", "first-row"].eachWithIndex { engine, index ->
+            def name = "single_file_merge_${index}"
+            createPk(name, "v INT", ", 'merge-engine'='${engine}'")
+            sql "INSERT INTO ${name} VALUES (1,11)"
+            if (engine == "first-row") {
+                // Paimon hides first-row level-0 files until compaction has deduplicated them.
+                spark_paimon """CALL paimon.sys.compact(
+                    table => '${database}.${name}', compact_strategy => 'full')"""
+            }
+            check("select id,v from ${name} order by id", [[1,11]], true)
+        }
+
+        createPk("supported_sum", "v DOUBLE", ", 'merge-engine'='aggregation', "
+                + "'fields.v.aggregate-function'='sum'")
+        sql "INSERT INTO supported_sum VALUES (1,10.0)"
+        // A single physical file can contain multiple versions from older Rust writers.
+        check("select v from supported_sum", [[10.0]], false)
+        sql "INSERT INTO supported_sum VALUES (1,3.0)"
+        check("select v from supported_sum", [[13.0]], false)
+
+        [false, true].each { decimal ->
+            [false, true].each { override ->
+                def name = "partial_default_${decimal}_${override}"
+                def type = decimal ? "DECIMAL(2,0)" : "TINYINT"
+                def first = decimal ? 99 : 127
+                // Keep the trailing operator so Groovy continues the assignment across lines.
+                def extra = ", 'merge-engine'='partial-update', 'fields.seq.sequence-group'='v', " +
+                        "'fields.default-aggregate-function'='sum'"
+                if (override) {
+                    extra += ", 'fields.v.aggregate-function'='max'"
+                }
+                createPk(name, "v ${type}, seq INT", extra)
+                sql "INSERT INTO ${name} VALUES (1,${first},1)"
+                // The reader must reject these engines even before a second file is written.
+                check("select cast(v as string) from ${name}", [[first.toString()]], false)
+                sql "INSERT INTO ${name} VALUES (1,1,2)"
+                if (decimal) {
+                    sql "INSERT INTO ${name} VALUES (1,-1,3)"
+                }
+                def expected = decimal ? "99" : override ? "127" : "-128"
+                check("select cast(v as string) from ${name}", [[expected]], false)
+            }
+        }
+
+        ["aggregation", "partial-update"].eachWithIndex { engine, index ->
+            def name = "unused_default_${index}"
+            def extra = ", 'merge-engine'='${engine}', 'fields.default-aggregate-function'='collect', " +
+                    "'fields.v.aggregate-function'='max'"
+            boolean partial = engine == "partial-update"
+            if (partial) {
+                extra += ", 'fields.seq.sequence-group'='v'"
+            }
+            createPk(name, partial ? "v INT, seq INT" : "v INT", extra)
+            sql "INSERT INTO ${name} VALUES (1,7${partial ? ',1' : ''})"
+            // An unused Java SPI default still fails Rust's global function-name validation.
+            check("select v from ${name}", [[7]], false)
+        }
+
+        createPk("insert_only_runs", "v STRING", ", 'read.batch-size'='1'")
+        3.times { run ->
+            sql """INSERT INTO insert_only_runs
+                SELECT number, concat('${run}', repeat('x',4096)) FROM numbers('number'='1025')"""
+        }
+        // Overlapping INSERT-only files have no tombstones, but Rust retains losing batches.
+        check("select count(*), min(length(v)), max(length(v)) from insert_only_runs "
+                + "where substring(v,1,1)='2'", [[1025,4097,4097]], false)
+
+        createPk("retract_sum", "v DOUBLE, kind STRING", ", 'merge-engine'='aggregation', "
+                + "'fields.v.aggregate-function'='sum', 'rowkind.field'='kind'")
+        sql "INSERT INTO retract_sum VALUES (1,10.0,'+I')"
+        sql "INSERT INTO retract_sum VALUES (1,3.0,'-D')"
+        check("select id,v from retract_sum", [[1,7.0]], false)
+
+        createPk("deleted_prefix", "v STRING, kind STRING", ", 'rowkind.field'='kind'")
+        sql """INSERT INTO deleted_prefix SELECT number, repeat('x',128), '+I'
+            FROM numbers('number'='4096')"""
+        sql """INSERT INTO deleted_prefix SELECT number, repeat('x',128), '-D'
+            FROM numbers('number'='4096')"""
+        // A scan-producing predicate avoids metadata COUNT shortcuts for an empty result.
+        check("select id from deleted_prefix where v is not null", [], false)
+        createPk("sparse_prefix", "v STRING, kind STRING", ", 'rowkind.field'='kind'")
+        sql """INSERT INTO sparse_prefix SELECT number, repeat('x',128), '+I'
+            FROM numbers('number'='4097')"""
+        sql """INSERT INTO sparse_prefix SELECT number, repeat('x',128), '-D'
+            FROM numbers('number'='4096')"""
+        check("select id from sparse_prefix order by id", [[4096]], false)
+    } finally {
+        saved.each { name, value -> sql "set ${name}='${value}'" }
+        sql "DROP DATABASE IF EXISTS ${database} FORCE"
+        sql "DROP CATALOG IF EXISTS ${catalog}"
+    }
+}
diff --git a/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_eq_for_null.groovy b/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_eq_for_null.groovy
new file mode 100644
index 00000000000..a5d4b3412ef
--- /dev/null
+++ b/regression-test/suites/external_table_p0/paimon/test_paimon_rust_reader_eq_for_null.groovy
@@ -0,0 +1,555 @@
+// 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.
+
+// NULL-safe equality (`<=>`, EQ_FOR_NULL) through the paimon rust reader.
+// The rust predicate converter must NOT push `a <=> b` down as `a IS NULL`:
+// with rows (NULL, NULL), (1, 1), (1, 2) that would wrongly drop (1, 1), and
+// rows dropped by the pushed filter cannot be recovered by the residual
+// conjunct. FE rewrites the literal forms (`a <=> 1` -> `a = 1`,
+// `a <=> NULL` -> `a IS NULL`) before they reach the BE, so only the
+// column-to-column form exercises EQ_FOR_NULL here; the literal forms still
+// guard the rewrite + pushdown chain end to end.
+//
+// Both differential legs run with force_jni_scanner=true: these parquet
+// append tables convert to raw native splits, which getSplits() would
+// otherwise prefer — both legs would silently use the native reader and
+// never reach the JNI / rust converters. The actual reader path is verified
+// per leg through the query profile (the rust reader's PaimonRustReader
+// timer group), and the join leg forces an IN runtime filter
+// (runtime_filter_type=1 + runtime_filter_wait_infinitely) that must be
+// planned onto the probe scan and arrive before the split opens.
+import org.apache.doris.regression.action.ProfileAction
+
+suite("test_paimon_rust_reader_eq_for_null", "p0,external,paimon") {
+    String enabled = context.config.otherConfigs.get("enablePaimonTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disabled paimon test")
+        return
+    }
+
+    String catalogName = "test_paimon_rust_eq_null"
+    String dbName = "test_paimon_rust_eq_null_db"
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String minioPort = context.config.otherConfigs.get("iceberg_minio_port")
+
+    // Use Spark to prepare the timestamp and nested-schema fixtures.
+    // Both columns stay nullable: `a <=> b` survives FE's NullSafeEqualToEqual
+    // rewrite (which only fires when one side is non-nullable / a NULL literal)
+    // precisely when both sides are nullable.
+    //
+    // t_frac_ts uses Spark TIMESTAMP_NTZ, which maps to Paimon TIMESTAMP
+    // (wall-clock, microsecond precision). Note Spark's plain TIMESTAMP maps
+    // to Paimon TIMESTAMP_LTZ, so the NTZ semantics must be spelled out in the
+    // DDL. spark.sql.timestampType=TIMESTAMP_NTZ makes the TIMESTAMP '...'
+    // literals parse as NTZ civil times too.
+    //
+    // The predicate literals are millisecond-aligned on purpose: FE truncates
+    // plan-time pushed-down timestamp predicates to 3 fractional digits, so a
+    // 6-digit literal would reach the readers truncated to milliseconds and
+    // the exact residual conjunct would then drop every row it kept — for the
+    // JNI, Rust and native readers alike. Literal conversion in the BE retains
+    // microseconds, but timestamp predicates remain
+    // residual because a DATETIMEV2 slot cannot reveal source nanosecond precision.
+    // ---- NaN differential on DOUBLE (total-ordering semantics) ----
+    // t_nan is created below: Doris defines NaN as equal to itself and
+    // greater than every finite value, but the pinned rust evaluator compares
+    // doubles with f64::partial_cmp (IEEE: NaN unordered, NaN != NaN), so the
+    // rust converter must NOT push DOUBLE predicates — a pushed `d > 1.0`
+    // would drop the stored NaN row Doris retains, and rows pruned by the
+    // rust filter cannot be recovered by the residual. The JNI path is
+    // unaffected: paimon-java's CompareUtils compares through Double.compareTo,
+    // which matches Doris's total ordering.
+    spark_paimon_multi """
+        SET spark.sql.timestampType=TIMESTAMP_NTZ;
+        CREATE DATABASE IF NOT EXISTS paimon.${dbName};
+        DROP TABLE IF EXISTS paimon.${dbName}.t_eq_null;
+        CREATE TABLE paimon.${dbName}.t_eq_null (
+            a INT, b INT
+        ) USING paimon;
+        INSERT INTO paimon.${dbName}.t_eq_null VALUES (NULL, NULL), (1, 1), (1, 2);
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_frac_ts;
+        CREATE TABLE paimon.${dbName}.t_frac_ts (
+            id INT, ts TIMESTAMP_NTZ
+        ) USING paimon;
+        INSERT INTO paimon.${dbName}.t_frac_ts VALUES
+            (1, TIMESTAMP '2024-01-01 00:00:00.123456'),
+            (2, TIMESTAMP '2024-01-01 00:00:00.123000'),
+            (3, TIMESTAMP '2024-01-02 00:00:00.999999');
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_frac_ts_dim;
+        CREATE TABLE paimon.${dbName}.t_frac_ts_dim (
+            id INT, ts TIMESTAMP_NTZ
+        ) USING paimon;
+        INSERT INTO paimon.${dbName}.t_frac_ts_dim VALUES
+            (1, TIMESTAMP '2024-01-01 00:00:00.123456'),
+            (2, TIMESTAMP '2024-01-02 00:00:00.999999');
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_narrowed;
+        CREATE TABLE paimon.${dbName}.t_narrowed (
+            id INT, v VARCHAR(10), amount DECIMAL(10,3)
+        ) USING paimon TBLPROPERTIES ('file.format' = 'parquet');
+        INSERT INTO paimon.${dbName}.t_narrowed VALUES (1, 'abcdef', 1.234), (2, 'xyzuvw', 2.345);
+        DROP TABLE IF EXISTS paimon.${dbName}.t_narrowed_dim;
+        CREATE TABLE paimon.${dbName}.t_narrowed_dim (
+            v VARCHAR(3), amount DECIMAL(10,2)
+        ) USING paimon;
+        INSERT INTO paimon.${dbName}.t_narrowed_dim VALUES ('abc', 1.23);
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_nan;
+        CREATE TABLE paimon.${dbName}.t_nan (
+            id INT, d DOUBLE
+        ) USING paimon;
+        INSERT INTO paimon.${dbName}.t_nan VALUES
+            (1, 1.5), (2, CAST('NaN' AS DOUBLE)), (3, NULL);
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_dedup_ignore_del;
+        CREATE TABLE paimon.${dbName}.t_dedup_ignore_del (
+            id INT, v INT
+        ) USING paimon TBLPROPERTIES (
+            'primary-key' = 'id',
+            'merge-engine' = 'deduplicate',
+            'deduplicate.ignore-delete' = 'true',
+            'file.format' = 'parquet'
+        );
+        INSERT INTO paimon.${dbName}.t_dedup_ignore_del VALUES (1, 11), (2, 22);
+        DELETE FROM paimon.${dbName}.t_dedup_ignore_del WHERE id = 1;
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_pu_remove_record_del;
+        CREATE TABLE paimon.${dbName}.t_pu_remove_record_del (
+            id INT, v INT
+        ) USING paimon TBLPROPERTIES (
+            'primary-key' = 'id',
+            'merge-engine' = 'partial-update',
+            'partial-update.remove-record-on-delete' = 'true',
+            'file.format' = 'parquet'
+        );
+        INSERT INTO paimon.${dbName}.t_pu_remove_record_del VALUES (1, 11), (2, 22);
+        DELETE FROM paimon.${dbName}.t_pu_remove_record_del WHERE id = 1;
+
+        DROP TABLE IF EXISTS paimon.${dbName}.t_nested_evo;
+        CREATE TABLE paimon.${dbName}.t_nested_evo (
+            id INT, s STRUCT<a: INT, b: STRING>
+        ) USING paimon TBLPROPERTIES (
+            'bucket' = '-1',
+            'file.format' = 'parquet'
+        );
+        INSERT INTO paimon.${dbName}.t_nested_evo VALUES (1, struct(10, 'x')), (2, struct(20, 'y'));
+        ALTER TABLE paimon.${dbName}.t_nested_evo ADD COLUMN s.c INT;
+        INSERT INTO paimon.${dbName}.t_nested_evo VALUES (3, struct(30, 'z', 33));
+    """
+
+    def originalSparkTimeZone = spark_paimon("SET spark.sql.session.timeZone")[0][1]
+    try {
+        spark_paimon "SET spark.sql.session.timeZone=Asia/Shanghai"
+        ["parquet", "orc"].each { format ->
+            // Legacy ORC LTZ encodes the writer JVM zone and requires the reader JVM
+            // to match it. Modern encoding keeps this routing test independent of host zones.
+            spark_paimon_multi """
+                DROP TABLE IF EXISTS paimon.${dbName}.t_ltz_${format};
+                CREATE TABLE paimon.${dbName}.t_ltz_${format} (id INT, ts_tz TIMESTAMP_LTZ)
+                    USING paimon TBLPROPERTIES (
+                        'bucket'='-1', 'file.format'='${format}',
+                        'orc.timestamp-ltz.legacy.type'='false');
+                INSERT INTO paimon.${dbName}.t_ltz_${format} VALUES
+                    (1, TIMESTAMP_LTZ '2025-01-01 00:00:00'),
+                    (2, TIMESTAMP_LTZ '2025-06-01 12:34:56.789'),
+                    (3, TIMESTAMP_LTZ '2025-12-31 23:59:59.999999'), (4, NULL);
+            """
+        }
+    } finally {
+        spark_paimon "SET spark.sql.session.timeZone=${originalSparkTimeZone}"
+    }
+
+    // The s3.region property is required: paimon-rust's S3 client rejects a
+    // missing region, while the JNI reader falls back to the SDK default.
+    sql """drop catalog if exists ${catalogName}"""
+    sql """
+        CREATE CATALOG ${catalogName} PROPERTIES (
+            'type' = 'paimon',
+            'paimon.catalog.type' = 'filesystem',
+            'warehouse' = 's3://warehouse/wh',
+            's3.endpoint' = 'http://${externalEnvIp}:${minioPort}',
+            's3.access_key' = 'admin',
+            's3.secret_key' = 'password',
+            's3.region' = 'us-east-1',
+            'use_path_style' = 'true'
+        );
+    """
+
+    // Capture the settings this suite overrides so finally can restore them.
+    def originalForceJni = sql("select @@force_jni_scanner")[0][0]
+    def originalEnableProfile = sql("select @@enable_profile")[0][0]
+    def originalRfWait = sql("select @@runtime_filter_wait_infinitely")[0][0]
+    def originalRfType = sql("select @@runtime_filter_type")[0][0]
+    def originalRfPrune = sql("select @@enable_runtime_filter_prune")[0][0]
+    def originalRfMaxIn = sql("select @@runtime_filter_max_in_num")[0][0]
+
+    try {
+        sql """switch ${catalogName}"""
+        sql """use ${dbName}"""
+        // Spark rejects narrowing in its analyzer. Doris forwards these explicit
+        // casts to Paimon and publishes a new schema without rewriting old files.
+        sql "ALTER TABLE t_narrowed MODIFY COLUMN v VARCHAR(3) NULL"
+        sql "ALTER TABLE t_narrowed MODIFY COLUMN amount DECIMAL(10,2) NULL"
+        sql """set enable_file_scanner_v2=true"""
+        // These tables are parquet append tables, whose DataSplits convert to
+        // raw native splits; without forcing, getSplits() would hand both legs
+        // to the native reader and bypass the JNI / rust converters entirely.
+        sql """set force_jni_scanner=true"""
+        sql "set enable_paimon_rust_reader=false"
+        // Verify the DDL really narrows historical values before testing filter pushdown.
+        assertEquals([[1, "abc", "1.23"], [2, "xyz", "2.35"]].toString(),
+                sql("select id,v,cast(amount as string) from t_narrowed order by id").toString())
+        // Profile capture for the reader-path verification below.
+        sql """set enable_profile=true"""
+        // The join leg must receive its IN runtime filter before the split
+        // opens, so the rust converter sees it in the conjuncts.
+        sql """set runtime_filter_wait_infinitely=true"""
+        // TRuntimeFilterType.IN == 1: force the IN runtime-filter shape.
+        sql """set runtime_filter_type=1"""
+
+        // Reuse the framework's profile readiness polling and configured HTTP credentials.
+        def profileAction = new ProfileAction(context)
+        def profileTextOf = { String query, boolean rustExpected = true ->
+            sql(query)
+            def queryId = sql("select last_query_id()")[0][0]
+            // Negative and zero-counter checks must never inspect a partially published profile.
... 1169 lines suppressed ...


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@doris.apache.org
For additional commands, e-mail: commits-help@doris.apache.org