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

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


The following commit(s) were added to refs/heads/branch-4.1 by this push:
     new 397ab8fc830 branch-4.1: [Enhancement](file scanner) Support row-id 
fetch in FileScannerV2 #67906 (#67922)
397ab8fc830 is described below

commit 397ab8fc830eada75328c08ac10d0da19dc49f04
Author: Gabriel <[email protected]>
AuthorDate: Tue Sep 15 09:24:06 2026 +0800

    branch-4.1: [Enhancement](file scanner) Support row-id fetch in 
FileScannerV2 #67906 (#67922)
    
    ### What problem does this PR solve?
    
    Backport #67906 to branch-4.1.
    
    TopN two-phase materialization can fetch selected Parquet and ORC file
    rows through FileScannerV2. The second phase follows the scanner rollout
    policy, avoids whole-chunk Parquet prefetch for sparse row selections,
    and preserves partition/generated/synthesized column categories and
    Iceberg file metadata. Exact-row fetches reject short results before
    reordering, fill output batches across sparse ranges, and use
    demand-page reads for Parquet projections.
    
    Compatibility adjustments for branch-4.1:
    - Preserve the existing Lance dataset-level uint64 row-ID fetch path,
    physical split scheduling, condition-cache state, and Variant
    projections.
    - Apply name-based column classification to the existing IcebergScanNode
    and keep relation-snapshot schema categories separate from physical
    column positions.
    - Use the existing Iceberg v3 row-lineage regression suite for
    eager/lazy comparisons; the master-only `_file`/`_pos` feature and
    connector framework are not prerequisites for this backport.
    
    ### Release note
    
    Support row-id fetch for Parquet and ORC in FileScannerV2.
    
    ### Check List (For Author)
    
    - FE: `FileQueryScanNodeTest` passed (14 tests, zero failures/errors);
    Maven validate passed with zero Checkstyle violations.
    - BE: 274 related ASAN unit tests passed across RowIdStorageReader,
    FileScanner, FileScannerV2, Parquet, and ORC. clang-format 16 passed for
    all 20 affected C++ files.
    - Regression evidence: all five focused cases failed before the fixes
    and passed afterward. A 1,024-row sparse selection now needs 8 output
    batches at a 128-row cap; a one-row fetch over 20 flat columns retains
    about 1.53 MiB of stream buffers instead of 160 MiB with default
    settings.
    - Validation limitation: the external Iceberg regression suite was not
    run locally.
    - Behavior changed: Yes. Supported TopN second-phase fetches use
    FileScannerV2 when enabled.
    - Does this need documentation: No.
---
 be/src/exec/operator/file_scan_operator.cpp        |  12 +-
 be/src/exec/operator/file_scan_operator.h          |   4 +-
 be/src/exec/rowid_fetcher.cpp                      | 138 +++++---
 be/src/exec/rowid_fetcher.h                        |  11 +
 be/src/exec/scan/file_scanner_v2.cpp               | 133 ++++++-
 be/src/exec/scan/file_scanner_v2.h                 |  15 +
 be/src/format_v2/file_reader.cpp                   |   8 +-
 be/src/format_v2/file_reader.h                     |  20 ++
 be/src/format_v2/orc/orc_reader.cpp                |  42 ++-
 be/src/format_v2/orc/orc_reader.h                  |   1 +
 be/src/format_v2/parquet/parquet_reader.h          |   1 +
 be/src/format_v2/parquet/parquet_scan.cpp          |  78 ++++-
 be/src/format_v2/parquet/parquet_scan.h            |   1 +
 .../parquet/reader/native_column_reader.cpp        |  18 +-
 .../parquet/reader/native_column_reader.h          |   5 +-
 be/src/format_v2/table_reader.cpp                  |   9 +-
 be/src/format_v2/table_reader.h                    |   7 +-
 be/test/exec/rowid_fetcher_test.cpp                | 243 +++++++++++++
 be/test/format_v2/orc/orc_reader_test.cpp          |  32 ++
 be/test/format_v2/parquet/parquet_scan_test.cpp    | 389 +++++++++++++++++++++
 .../apache/doris/datasource/FileQueryScanNode.java |  29 +-
 .../datasource/iceberg/source/IcebergScanNode.java |  10 +-
 .../doris/datasource/FileQueryScanNodeTest.java    |  40 +++
 gensrc/thrift/PlanNodes.thrift                     |   3 +
 ...test_iceberg_v3_row_lineage_query_insert.groovy |  23 ++
 25 files changed, 1162 insertions(+), 110 deletions(-)

diff --git a/be/src/exec/operator/file_scan_operator.cpp 
b/be/src/exec/operator/file_scan_operator.cpp
index c33fe2f8bd8..f8a72456e84 100644
--- a/be/src/exec/operator/file_scan_operator.cpp
+++ b/be/src/exec/operator/file_scan_operator.cpp
@@ -137,13 +137,13 @@ ScannerScheduler* 
FileScanLocalState::scan_scheduler(RuntimeState* state) const
 bool FileScanLocalState::TEST_should_use_file_scanner_v2(const TQueryOptions& 
query_options,
                                                          bool is_load,
                                                          const 
TFileScanRangeParams& scan_params) {
-    return _should_use_file_scanner_v2(query_options, is_load, scan_params);
+    return should_use_file_scanner_v2(query_options, is_load, scan_params);
 }
 #endif
 
-bool FileScanLocalState::_should_use_file_scanner_v2(const TQueryOptions& 
query_options,
-                                                     bool is_load,
-                                                     const 
TFileScanRangeParams& scan_params) {
+bool FileScanLocalState::should_use_file_scanner_v2(const TQueryOptions& 
query_options,
+                                                    bool is_load,
+                                                    const 
TFileScanRangeParams& scan_params) {
     const bool is_transactional_hive =
             scan_params.__isset.table_format_params &&
             scan_params.table_format_params.table_format_type == 
"transactional_hive";
@@ -156,7 +156,7 @@ bool 
FileScanLocalState::_can_generate_physical_splits(const TQueryOptions& quer
                                                        bool is_load,
                                                        const 
TFileScanRangeParams& scan_params,
                                                        const TFileRangeDesc& 
range) {
-    if (!_should_use_file_scanner_v2(query_options, is_load, scan_params)) {
+    if (!should_use_file_scanner_v2(query_options, is_load, scan_params)) {
         return false;
     }
     const auto format = range.__isset.format_type ? range.format_type : 
scan_params.format_type;
@@ -210,7 +210,7 @@ Status 
FileScanLocalState::_init_scanners(std::list<ScannerSPtr>* scanners) {
             
state()->desc_tbl().get_tuple_descriptor(scan_params->src_tuple_id) != nullptr;
     // TODO: Use scanner v2 for all queries.
     const bool use_file_scanner_v2 =
-            _should_use_file_scanner_v2(state()->query_options(), is_load, 
*scan_params);
+            should_use_file_scanner_v2(state()->query_options(), is_load, 
*scan_params);
     _operator_profile->add_info_string("UseScannerV2", use_file_scanner_v2 ? 
"true" : "false");
     const auto* output_tuple_desc = 
state()->desc_tbl().get_tuple_descriptor(_output_tuple_id);
     DORIS_CHECK(output_tuple_desc != nullptr);
diff --git a/be/src/exec/operator/file_scan_operator.h 
b/be/src/exec/operator/file_scan_operator.h
index 862ff4d438d..80c16e1177d 100644
--- a/be/src/exec/operator/file_scan_operator.h
+++ b/be/src/exec/operator/file_scan_operator.h
@@ -56,6 +56,8 @@ public:
     int max_scanners_concurrency(RuntimeState* state) const override;
     int min_scanners_concurrency(RuntimeState* state) const override;
     ScannerScheduler* scan_scheduler(RuntimeState* state) const override;
+    static bool should_use_file_scanner_v2(const TQueryOptions& query_options, 
bool is_load,
+                                           const TFileScanRangeParams& 
scan_params);
 #ifdef BE_TEST
     static bool TEST_should_use_file_scanner_v2(const TQueryOptions& 
query_options, bool is_load,
                                                 const TFileScanRangeParams& 
scan_params);
@@ -80,8 +82,6 @@ private:
         return PushDownType::PARTIAL_ACCEPTABLE;
     }
     bool _push_down_topn(const RuntimePredicate& predicate) override;
-    static bool _should_use_file_scanner_v2(const TQueryOptions& 
query_options, bool is_load,
-                                            const TFileScanRangeParams& 
scan_params);
     static bool _can_generate_physical_splits(const TQueryOptions& 
query_options, bool is_load,
                                               const TFileScanRangeParams& 
scan_params,
                                               const TFileRangeDesc& range);
diff --git a/be/src/exec/rowid_fetcher.cpp b/be/src/exec/rowid_fetcher.cpp
index 61c5a529d73..8c3b0023831 100644
--- a/be/src/exec/rowid_fetcher.cpp
+++ b/be/src/exec/rowid_fetcher.cpp
@@ -56,7 +56,9 @@
 #include "core/data_type_serde/data_type_serde.h"
 #include "core/string_ref.h"
 #include "core/types.h"
+#include "exec/operator/file_scan_operator.h"
 #include "exec/scan/file_scanner.h"
+#include "exec/scan/file_scanner_v2.h"
 #include "format/orc/vorc_reader.h"
 #include "format/parquet/vparquet_reader.h"
 #include "format_v2/table/lance_reader.h"
@@ -776,6 +778,69 @@ const std::string 
RowIdStorageReader::TopNLazyMaterializationSecondPhaseRowsRead
 const std::string 
RowIdStorageReader::TopNLazyMaterializationSecondPhaseSegmentsRead =
         "TopNLazyMaterializationSecondPhaseSegmentsRead";
 
+bool RowIdStorageReader::should_use_file_scanner_v2(const TQueryOptions& 
query_options,
+                                                    const 
TFileScanRangeParams& scan_params,
+                                                    const TFileRangeDesc& 
range) {
+    const auto format_type =
+            range.__isset.format_type ? range.format_type : 
scan_params.format_type;
+    // Phase two inherits the query options, including the Thrift presence 
bit. Reuse phase one's
+    // policy so disabling V2 (or an older payload omitting the option) also 
keeps row fetches on V1.
+    return FileScanLocalState::should_use_file_scanner_v2(query_options, 
false, scan_params) &&
+           (format_type == TFileFormatType::FORMAT_PARQUET ||
+            format_type == TFileFormatType::FORMAT_ORC) &&
+           FileScannerV2::is_supported(scan_params, range);
+}
+
+TFileRangeDesc RowIdStorageReader::build_external_fetch_range(const 
TFileRangeDesc& source_range) {
+    // Rows were selected after delete filtering. Preserve the original path 
and row lineage
+    // needed by virtual columns, and do not mutate the FileMapping shared by 
other fetches.
+    auto range = source_range;
+    range.table_format_params.iceberg_params.__set_delete_files({});
+    range.table_format_params.transactional_hive_params = 
TTransactionalHiveDesc {};
+    return range;
+}
+
+TFileScanRangeParams RowIdStorageReader::build_external_scan_params(
+        const TFileScanRangeParams& source_params, const TFileRangeDesc& range,
+        const std::vector<SlotDescriptor>& scan_slots,
+        const std::vector<uint32_t>& scan_column_idxs) {
+    DORIS_CHECK(scan_slots.size() == scan_column_idxs.size());
+    auto params = source_params;
+    params.required_slots.clear();
+    params.column_idxs.clear();
+    params.slot_name_to_schema_pos.clear();
+    const std::set partition_names(range.columns_from_path_keys.begin(),
+                                   range.columns_from_path_keys.end());
+    for (size_t slot_idx = 0; slot_idx < scan_slots.size(); ++slot_idx) {
+        const auto& slot = scan_slots[slot_idx];
+        const auto column_idx = scan_column_idxs[slot_idx];
+        TFileScanSlotInfo slot_info;
+        slot_info.__set_slot_id(slot.id());
+        // Hive V2 checks the Thrift presence bit before trusting 
is_file_slot. Without it,
+        // partition columns consume physical file indexes and invalidate the 
rebuilt projection.
+        bool is_file_slot = !partition_names.contains(slot.col_name());
+        if (source_params.__isset.column_name_to_category) {
+            // Lazy metadata slots may be absent from phase one's 
required_slots and have new
+            // slot IDs here. The pinned schema's name map preserves their 
original categories.
+            const auto it = 
source_params.column_name_to_category.find(slot.col_name());
+            const auto category = it != 
source_params.column_name_to_category.end()
+                                          ? it->second
+                                          : TColumnCategory::REGULAR;
+            slot_info.__set_category(category);
+            is_file_slot =
+                    category == TColumnCategory::REGULAR || category == 
TColumnCategory::GENERATED;
+        }
+        slot_info.__set_is_file_slot(is_file_slot);
+        if (is_file_slot) {
+            params.column_idxs.emplace_back(column_idx);
+        }
+        params.default_value_of_src_slot.emplace(slot.id(), TExpr {});
+        params.required_slots.emplace_back(slot_info);
+        params.slot_name_to_schema_pos.emplace(slot.col_name(), column_idx);
+    }
+    return params;
+}
+
 Status RowIdStorageReader::read_lance_rows_by_row_ids(
         const TFileRangeDesc& scan_range_desc, const std::vector<uint64_t>& 
row_ids,
         const std::vector<SlotDescriptor>& slots, RuntimeState* runtime_state,
@@ -851,13 +916,7 @@ Status 
RowIdStorageReader::read_external_row_from_file_mapping(
     scan_blocks[idx] = Block(slots, read_ids.size());
 
     auto& external_info = file_mapping->get_external_file_info();
-    auto& scan_range_desc = external_info.scan_range_desc;
-
-    // Clear to avoid reading iceberg position delete file...
-    scan_range_desc.table_format_params.iceberg_params = TIcebergFileDesc {};
-
-    // Clear to avoid reading hive transactional delete delta file...
-    scan_range_desc.table_format_params.transactional_hive_params = 
TTransactionalHiveDesc {};
+    auto scan_range_desc = 
build_external_fetch_range(external_info.scan_range_desc);
 
     std::unique_ptr<RuntimeProfile> sub_runtime_profile =
             std::make_unique<RuntimeProfile>("ExternalRowIDFetcher");
@@ -868,9 +927,7 @@ Status 
RowIdStorageReader::read_external_row_from_file_mapping(
                 scan_range_desc, read_ids, slots, runtime_state.get(), 
sub_runtime_profile.get(),
                 rpc_scan_params, &scan_blocks[idx], &fetch_statistics[idx]));
     } else {
-        // Parquet/ORC row IDs are consumed as row ordinals within the exact 
physical file range
-        // recorded by phase one. Keep using FileScanner so the format reader 
can resolve those
-        // ordinals against that range; unlike Lance, ranges cannot be merged 
at dataset level.
+        // Parquet/ORC IDs remain signed physical file positions; Lance keeps 
native uint64 IDs.
         std::list<int64_t> legacy_read_ids;
         for (const auto row_id : read_ids) {
             if (row_id > 
static_cast<uint64_t>(std::numeric_limits<int64_t>::max())) {
@@ -879,18 +936,29 @@ Status 
RowIdStorageReader::read_external_row_from_file_mapping(
             }
             legacy_read_ids.emplace_back(static_cast<int64_t>(row_id));
         }
-        std::unique_ptr<FileScanner> vfile_scanner_ptr =
-                FileScanner::create_unique(runtime_state.get(), 
sub_runtime_profile.get(),
-                                           &rpc_scan_params, 
&colname_to_slot_id, &tuple_desc);
-
-        
RETURN_IF_ERROR(vfile_scanner_ptr->prepare_for_read_lines(scan_range_desc));
-        RETURN_IF_ERROR(vfile_scanner_ptr->read_lines_from_range(
-                scan_range_desc, legacy_read_ids, &scan_blocks[idx], 
external_info,
-                &fetch_statistics[idx].init_reader_ms, 
&fetch_statistics[idx].get_block_ms));
+        if (should_use_file_scanner_v2(runtime_state->query_options(), 
rpc_scan_params,
+                                       scan_range_desc)) {
+            auto file_scanner = FileScannerV2::create_unique(
+                    runtime_state.get(), sub_runtime_profile.get(), 
&rpc_scan_params,
+                    &colname_to_slot_id, &tuple_desc);
+            RETURN_IF_ERROR(file_scanner->read_by_rows(
+                    scan_range_desc, legacy_read_ids, &scan_blocks[idx],
+                    &fetch_statistics[idx].init_reader_ms, 
&fetch_statistics[idx].get_block_ms));
+        } else {
+            // Keep phase two on V1 whenever the rollout policy or table 
format requires it.
+            std::unique_ptr<FileScanner> file_scanner =
+                    FileScanner::create_unique(runtime_state.get(), 
sub_runtime_profile.get(),
+                                               &rpc_scan_params, 
&colname_to_slot_id, &tuple_desc);
+
+            
RETURN_IF_ERROR(file_scanner->prepare_for_read_lines(scan_range_desc));
+            RETURN_IF_ERROR(file_scanner->read_lines_from_range(
+                    scan_range_desc, legacy_read_ids, &scan_blocks[idx], 
external_info,
+                    &fetch_statistics[idx].init_reader_ms, 
&fetch_statistics[idx].get_block_ms));
+        }
     }
 
     auto file_read_bytes_counter =
-            
sub_runtime_profile->get_counter(FileScanner::FileReadBytesProfile);
+            
sub_runtime_profile->get_counter(FileScannerV2::FileReadBytesProfile);
 
     if (file_read_bytes_counter != nullptr) {
         fetch_statistics[idx].file_read_bytes = PrettyPrinter::print(
@@ -898,7 +966,7 @@ Status 
RowIdStorageReader::read_external_row_from_file_mapping(
     }
 
     auto file_read_times_counter =
-            sub_runtime_profile->get_counter(FileScanner::FileReadTimeProfile);
+            
sub_runtime_profile->get_counter(FileScannerV2::FileReadTimeProfile);
     if (file_read_times_counter != nullptr) {
         fetch_statistics[idx].file_read_times = PrettyPrinter::print(
                 file_read_times_counter->value(), 
file_read_times_counter->type());
@@ -947,30 +1015,16 @@ Status RowIdStorageReader::read_batch_external_row(
             }
         }
 
-        rpc_scan_params.required_slots.clear();
-        rpc_scan_params.column_idxs.clear();
-        rpc_scan_params.slot_name_to_schema_pos.clear();
-
-        std::set 
partition_name_set(first_scan_range_desc.columns_from_path_keys.begin(),
-                                    
first_scan_range_desc.columns_from_path_keys.end());
-        for (auto slot_idx = 0; slot_idx < slots.size(); ++slot_idx) {
+        std::vector<uint32_t> scan_column_idxs;
+        scan_column_idxs.reserve(slots.size());
+        for (int slot_idx = 0; slot_idx < slots.size(); ++slot_idx) {
             auto& slot = slots[slot_idx];
             tuple_desc.add_slot(&slot);
             colname_to_slot_id.emplace(slot.col_name(), slot.id());
-            TFileScanSlotInfo slot_info;
-            slot_info.slot_id = slot.id();
-            auto column_idx = request_block_desc.column_idxs(slot_idx);
-            if (partition_name_set.contains(slot.col_name())) {
-                //This is partition column.
-                slot_info.is_file_slot = false;
-            } else {
-                rpc_scan_params.column_idxs.emplace_back(column_idx);
-                slot_info.is_file_slot = true;
-            }
-            rpc_scan_params.default_value_of_src_slot.emplace(slot.id(), TExpr 
{});
-            rpc_scan_params.required_slots.emplace_back(slot_info);
-            rpc_scan_params.slot_name_to_schema_pos.emplace(slot.col_name(), 
column_idx);
+            
scan_column_idxs.emplace_back(request_block_desc.column_idxs(slot_idx));
         }
+        rpc_scan_params = build_external_scan_params(rpc_scan_params, 
first_scan_range_desc, slots,
+                                                     scan_column_idxs);
 
         const auto& query_options = id_file_map->get_query_options();
         const auto& query_globals = id_file_map->get_query_globals();
@@ -1174,9 +1228,9 @@ Status RowIdStorageReader::read_batch_external_row(
                                          std::to_string(*get_block_avg_ms) + 
"ms");
         runtime_profile->add_info_string(FileReadLinesProfile,
                                          
fmt::to_string(file_read_lines_buffer));
-        runtime_profile->add_info_string(FileScanner::FileReadBytesProfile,
+        runtime_profile->add_info_string(FileScannerV2::FileReadBytesProfile,
                                          
fmt::to_string(file_read_bytes_buffer));
-        runtime_profile->add_info_string(FileScanner::FileReadTimeProfile,
+        runtime_profile->add_info_string(FileScannerV2::FileReadTimeProfile,
                                          
fmt::to_string(file_read_times_buffer));
         for (const auto& [time_name, time_value] : lance_fetch_times_ns) {
             runtime_profile->add_info_string(time_name,
diff --git a/be/src/exec/rowid_fetcher.h b/be/src/exec/rowid_fetcher.h
index ceb7a51c1a0..ab8019e8f32 100644
--- a/be/src/exec/rowid_fetcher.h
+++ b/be/src/exec/rowid_fetcher.h
@@ -40,6 +40,7 @@ namespace doris {
 class DorisNodesInfo;
 class RuntimeProfile;
 class RuntimeState;
+class TQueryOptions;
 class TupleDescriptor;
 namespace io {
 enum class FileCacheMissPolicy : uint8_t;
@@ -122,7 +123,17 @@ public:
     static Status read_by_rowids(const PMultiGetRequest& request, 
PMultiGetResponse* response);
     static Status read_by_rowids(const PMultiGetRequestV2& request, 
PMultiGetResponseV2* response);
 
+    static bool should_use_file_scanner_v2(const TQueryOptions& query_options,
+                                           const TFileScanRangeParams& 
scan_params,
+                                           const TFileRangeDesc& range);
+
 private:
+    friend class RowIdStorageReaderTest;
+    static TFileRangeDesc build_external_fetch_range(const TFileRangeDesc& 
source_range);
+    static TFileScanRangeParams build_external_scan_params(
+            const TFileScanRangeParams& source_params, const TFileRangeDesc& 
range,
+            const std::vector<SlotDescriptor>& scan_slots,
+            const std::vector<uint32_t>& scan_column_idxs);
     struct ExternalFetchStatistics;
 
     static Status read_doris_format_row(
diff --git a/be/src/exec/scan/file_scanner_v2.cpp 
b/be/src/exec/scan/file_scanner_v2.cpp
index ac775fd94bf..c0cdfaf7b78 100644
--- a/be/src/exec/scan/file_scanner_v2.cpp
+++ b/be/src/exec/scan/file_scanner_v2.cpp
@@ -75,8 +75,12 @@
 #include "runtime/runtime_state.h"
 #include "service/backend_options.h"
 #include "storage/id_manager.h"
+#include "util/stopwatch.hpp"
 
 namespace doris {
+const std::string FileScannerV2::FileReadBytesProfile = "FileReadBytes";
+const std::string FileScannerV2::FileReadTimeProfile = "FileReadTime";
+
 namespace {
 
 constexpr int kIcebergPositionDeleteContent = 1;
@@ -432,12 +436,12 @@ Status FileScannerV2::init(RuntimeState* state, const 
VExprContextSPtrs& conjunc
                                                            
file_scan_profile::SCANNER, 1);
     _file_counter = ADD_CHILD_COUNTER_WITH_LEVEL(profile, "FileNumber", 
TUnit::UNIT,
                                                  file_scan_profile::SCANNER, 
1);
-    _file_read_bytes_counter = ADD_CHILD_COUNTER_WITH_LEVEL(profile, 
"FileReadBytes", TUnit::BYTES,
-                                                            
file_scan_profile::IO, 1);
+    _file_read_bytes_counter = ADD_CHILD_COUNTER_WITH_LEVEL(profile, 
FileReadBytesProfile,
+                                                            TUnit::BYTES, 
file_scan_profile::IO, 1);
     _file_read_calls_counter = ADD_CHILD_COUNTER_WITH_LEVEL(profile, 
"FileReadCalls", TUnit::UNIT,
                                                             
file_scan_profile::IO, 1);
     _file_read_time_counter =
-            ADD_CHILD_TIMER_WITH_LEVEL(profile, "FileReadTime", 
file_scan_profile::IO, 1);
+            ADD_CHILD_TIMER_WITH_LEVEL(profile, FileReadTimeProfile, 
file_scan_profile::IO, 1);
     _adaptive_batch_predicted_rows_counter = ADD_CHILD_COUNTER_WITH_LEVEL(
             profile, "AdaptiveBatchPredictedRows", TUnit::UNIT, 
file_scan_profile::SCANNER, 1);
     _adaptive_batch_actual_bytes_counter = ADD_CHILD_COUNTER_WITH_LEVEL(
@@ -715,18 +719,21 @@ Status FileScannerV2::_init_table_reader(const 
TFileRangeDesc& range) {
     VExprContextSPtrs table_conjuncts;
     RETURN_IF_ERROR(_build_table_conjuncts(&table_conjuncts));
     std::optional<std::vector<format::GlobalIndex>> push_down_count_columns;
-    const auto& push_down_count_slot_ids = 
_local_state->get_push_down_count_slot_ids();
-    if (push_down_count_slot_ids.has_value()) {
-        push_down_count_columns.emplace();
-        push_down_count_columns->reserve(push_down_count_slot_ids->size());
-        for (const auto slot_id : *push_down_count_slot_ids) {
-            const auto global_index_it = 
_slot_id_to_global_index.find(slot_id);
-            if (global_index_it == _slot_id_to_global_index.end()) {
-                return Status::InternalError(
-                        "Pushed-down COUNT argument is not a projected file 
scan slot, slot_id={}",
-                        slot_id);
+    if (_local_state != nullptr) {
+        const auto& push_down_count_slot_ids = 
_local_state->get_push_down_count_slot_ids();
+        if (push_down_count_slot_ids.has_value()) {
+            push_down_count_columns.emplace();
+            push_down_count_columns->reserve(push_down_count_slot_ids->size());
+            for (const auto slot_id : *push_down_count_slot_ids) {
+                const auto global_index_it = 
_slot_id_to_global_index.find(slot_id);
+                if (global_index_it == _slot_id_to_global_index.end()) {
+                    return Status::InternalError(
+                            "Pushed-down COUNT argument is not a projected 
file scan slot, "
+                            "slot_id={}",
+                            slot_id);
+                }
+                push_down_count_columns->push_back(global_index_it->second);
             }
-            push_down_count_columns->push_back(global_index_it->second);
         }
     }
     RETURN_IF_ERROR(_table_reader->init({
@@ -736,15 +743,106 @@ Status FileScannerV2::_init_table_reader(const 
TFileRangeDesc& range) {
             .scan_params = const_cast<TFileScanRangeParams*>(_params),
             .io_ctx = _io_ctx,
             .runtime_state = _state,
-            .scanner_profile = _local_state->scanner_profile(),
+            .scanner_profile = _local_state != nullptr ? 
_local_state->scanner_profile() : _profile,
             .file_slot_descs = &_file_slot_descs,
-            .push_down_agg_type = _local_state->get_push_down_agg_type(),
+            .push_down_agg_type = _local_state != nullptr ? 
_local_state->get_push_down_agg_type()
+                                                          : 
TPushAggOp::type::NONE,
             .push_down_count_columns = std::move(push_down_count_columns),
-            .condition_cache_digest = 
_local_state->get_condition_cache_digest(),
+            .condition_cache_digest =
+                    _local_state != nullptr ? 
_local_state->get_condition_cache_digest() : 0,
     }));
     return Status::OK();
 }
 
+Status FileScannerV2::read_by_rows(const TFileRangeDesc& range, const 
std::list<int64_t>& row_ids,
+                                   Block* result_block, int64_t* 
init_reader_ms,
+                                   int64_t* get_block_ms) {
+    DORIS_CHECK(result_block != nullptr);
+    DORIS_CHECK(init_reader_ms != nullptr);
+    DORIS_CHECK(get_block_ms != nullptr);
+    _current_range = range;
+    RETURN_IF_ERROR(_validate_scan_range(*_params, range));
+    const auto format_type = get_range_format_type(*_params, range);
+    if (format_type != TFileFormatType::FORMAT_PARQUET &&
+        format_type != TFileFormatType::FORMAT_ORC) {
+        return Status::NotSupported(
+                "FileScannerV2 row-id fetch supports only Parquet and ORC, 
file format={}",
+                to_string(format_type));
+    }
+
+    _file_cache_statistics = std::make_unique<io::FileCacheStatistics>();
+    _file_reader_stats = std::make_unique<io::FileReaderStats>();
+    _file_read_bytes_counter =
+            ADD_COUNTER_WITH_LEVEL(_profile, FileReadBytesProfile, 
TUnit::BYTES, 1);
+    _file_read_time_counter = ADD_TIMER_WITH_LEVEL(_profile, 
FileReadTimeProfile, 1);
+    RETURN_IF_ERROR(_init_io_ctx());
+    _io_ctx->file_cache_stats = _file_cache_statistics.get();
+    _io_ctx->file_reader_stats = _file_reader_stats.get();
+    _io_ctx->is_disposable = _state->query_options().disable_file_cache;
+
+    MonotonicStopWatch init_watch;
+    init_watch.start();
+    auto init_status = [&]() -> Status {
+        RETURN_IF_ERROR(_create_table_reader_for_format(range, 
&_table_reader));
+        DORIS_CHECK(_table_reader != nullptr);
+        RETURN_IF_ERROR(_init_expr_ctxes());
+        RETURN_IF_ERROR(_init_table_reader(range));
+        std::map<std::string, Field> partition_values;
+        RETURN_IF_ERROR(_generate_partition_values(range, &partition_values));
+        format::FileFormat current_split_format;
+        RETURN_IF_ERROR(_to_file_format(format_type, &current_split_format));
+        std::vector<int64_t> requested_rows(row_ids.begin(), row_ids.end());
+        _table_reader->set_batch_size(std::max<size_t>(requested_rows.size(), 
1));
+        RETURN_IF_ERROR(_table_reader->prepare_split({
+                .partition_values = std::move(partition_values),
+                .conjuncts = std::nullopt,
+                .partition_prune_conjuncts = {},
+                .all_runtime_filters_applied = true,
+                .condition_cache_digest = 0,
+                .cache = nullptr,
+                .current_range = range,
+                .current_split_format = current_split_format,
+                .file_context = nullptr,
+                .condition_cache_source_range = std::nullopt,
+                .condition_cache_split_context = nullptr,
+                .global_rowid_context = std::nullopt,
+                .row_ids = std::move(requested_rows),
+        }));
+        return Status::OK();
+    }();
+    *init_reader_ms += init_watch.elapsed_time() / 1000 / 1000;
+    RETURN_IF_ERROR(init_status);
+
+    MonotonicStopWatch read_watch;
+    read_watch.start();
+    auto read_status = [&]() -> Status {
+        Block read_block = result_block->clone_empty();
+        ScopedMutableBlock mutable_result(result_block);
+        bool eof = false;
+        while (!eof) {
+            RETURN_IF_ERROR(_table_reader->get_block(&read_block, &eof));
+            if (read_block.rows() > 0) {
+                
RETURN_IF_ERROR(mutable_result.mutable_block().merge(read_block));
+            }
+        }
+        return Status::OK();
+    }();
+    *get_block_ms += read_watch.elapsed_time() / 1000 / 1000;
+    RETURN_IF_ERROR(read_status);
+
+    RETURN_IF_ERROR(_table_reader->close());
+    _table_reader.reset();
+    COUNTER_UPDATE(_file_read_bytes_counter, _file_reader_stats->read_bytes);
+    COUNTER_UPDATE(_file_read_time_counter, _file_reader_stats->read_time_ns);
+    // Reordering uses a dense position for every requested ID. A replaced or 
truncated file
+    // must fail here instead of exposing a short source column to unchecked 
indexed inserts.
+    if (result_block->rows() != row_ids.size()) {
+        return Status::Corruption("FileScannerV2 row-ID fetch returned {} 
rows, expected {}",
+                                  result_block->rows(), row_ids.size());
+    }
+    return Status::OK();
+}
+
 Status FileScannerV2::_create_table_reader_for_format(
         const TFileRangeDesc& range, std::unique_ptr<format::TableReader>* 
reader) const {
     DORIS_CHECK(reader != nullptr);
@@ -825,6 +923,7 @@ Status FileScannerV2::_prepare_table_reader_split(const 
TFileRangeDesc& range,
             .format_split_id_end = _current_split.format_split_id_end,
             .global_rowid_context =
                     
_create_global_rowid_context(_current_split.source_identity_range()),
+            .row_ids = std::nullopt,
     }));
     return Status::OK();
 }
diff --git a/be/src/exec/scan/file_scanner_v2.h 
b/be/src/exec/scan/file_scanner_v2.h
index 5f6869a8312..dd10d2952b1 100644
--- a/be/src/exec/scan/file_scanner_v2.h
+++ b/be/src/exec/scan/file_scanner_v2.h
@@ -17,6 +17,7 @@
 
 #pragma once
 
+#include <list>
 #include <map>
 #include <memory>
 #include <optional>
@@ -53,6 +54,8 @@ class FileScannerV2 final : public Scanner {
 public:
     static constexpr const char* NAME = "FileScannerV2";
     static constexpr size_t ADAPTIVE_BATCH_INITIAL_PROBE_ROWS = 32;
+    static const std::string FileReadBytesProfile;
+    static const std::string FileReadTimeProfile;
 
     struct RealtimeCounterDeltas {
         int64_t scan_rows = 0;
@@ -139,6 +142,18 @@ public:
                   ShardedKVCache* kv_cache,
                   const std::unordered_map<std::string, int>* 
colname_to_slot_id);
 
+    // Standalone scanner used by TopN two-phase materialization.
+    FileScannerV2(RuntimeState* state, RuntimeProfile* profile, const 
TFileScanRangeParams* params,
+                  const std::unordered_map<std::string, int>* 
colname_to_slot_id,
+                  TupleDescriptor* tuple_desc)
+            : Scanner(state, profile), _params(params) {
+        (void)colname_to_slot_id;
+        _output_tuple_desc = tuple_desc;
+    }
+
+    Status read_by_rows(const TFileRangeDesc& range, const std::list<int64_t>& 
row_ids,
+                        Block* result_block, int64_t* init_reader_ms, int64_t* 
get_block_ms);
+
     Status init(RuntimeState* state, const VExprContextSPtrs& conjuncts) 
override;
     Status _open_impl(RuntimeState* state) override;
     Status close(RuntimeState* state) override;
diff --git a/be/src/format_v2/file_reader.cpp b/be/src/format_v2/file_reader.cpp
index 34fd4e5b273..894cd49fa6b 100644
--- a/be/src/format_v2/file_reader.cpp
+++ b/be/src/format_v2/file_reader.cpp
@@ -73,7 +73,13 @@ std::string FileScanRequest::debug_string() const {
         }
         out << column_id << ":" << block_position;
     }
-    out << "}, conjunct_count=" << conjuncts.size() << ", 
residual_predicate_columns="
+    out << "}, row_ids=";
+    if (row_ids.has_value()) {
+        out << join_debug_strings(*row_ids, [](int64_t row_id) { return 
std::to_string(row_id); });
+    } else {
+        out << "nullopt";
+    }
+    out << ", conjunct_count=" << conjuncts.size() << ", 
residual_predicate_columns="
         << join_debug_strings(
                    residual_predicate_columns,
                    [](LocalColumnId column_id) { return 
std::to_string(column_id.value()); })
diff --git a/be/src/format_v2/file_reader.h b/be/src/format_v2/file_reader.h
index 586f9dd4ff5..8d0cc487676 100644
--- a/be/src/format_v2/file_reader.h
+++ b/be/src/format_v2/file_reader.h
@@ -18,6 +18,7 @@
 #include <algorithm>
 #include <cstddef>
 #include <cstdint>
+#include <functional>
 #include <limits>
 #include <map>
 #include <memory>
@@ -117,6 +118,12 @@ struct FileScanRequest {
     // predicate_columns, the value is semantically required and must still be 
validated and read.
     std::vector<LocalColumnId> count_star_placeholder_columns;
 
+    // Absolute zero-based file row positions selected by a row-id fetch. A 
present but empty
+    // vector means that no rows should be read; nullopt keeps the normal 
sequential scan path.
+    // Readers require strictly increasing positions so they can seek forward 
without duplicating
+    // output rows.
+    std::optional<std::vector<int64_t>> row_ids = std::nullopt;
+
     // Table formats may assign semantics that legacy physical files do not 
encode. Each path here
     // identifies an unannotated Parquet group that the physical reader must 
validate and decode as
     // Variant. Keeping this explicit prevents generic Parquet scans from 
guessing based on names.
@@ -409,8 +416,21 @@ public:
     virtual std::unique_ptr<TableColumnMapper> create_column_mapper(
             TableColumnMapperOptions options) const;
 
+    virtual bool supports_rowid_fetch() const { return false; }
+
     // Open the file reader with file-local scan request. The file reader 
should initialize its internal state according to the request, but does not 
need to interpret table/global schema semantics. For example, all schema 
change, filter localization, default/generated/partition columns should be 
handled in table reader layer. This method can only be called after init() 
successfully.
     virtual Status open(std::shared_ptr<FileScanRequest> request) {
+        if (request->row_ids.has_value()) {
+            if (!supports_rowid_fetch()) {
+                return Status::NotSupported("File reader does not support 
row-id fetch");
+            }
+            const auto& row_ids = *request->row_ids;
+            if (std::ranges::any_of(row_ids, [](int64_t row_id) { return 
row_id < 0; }) ||
+                std::ranges::adjacent_find(row_ids, std::greater_equal<>()) != 
row_ids.end()) {
+                return Status::InvalidArgument(
+                        "Row-id fetch requires non-negative, strictly 
increasing file row ids");
+            }
+        }
         _request = std::move(request);
         return Status::OK();
     }
diff --git a/be/src/format_v2/orc/orc_reader.cpp 
b/be/src/format_v2/orc/orc_reader.cpp
index 868f451963f..85754b9997a 100644
--- a/be/src/format_v2/orc/orc_reader.cpp
+++ b/be/src/format_v2/orc/orc_reader.cpp
@@ -755,6 +755,7 @@ struct OrcReaderScanState {
     std::vector<StripeRange> selected_stripe_ranges;
     size_t current_stripe_range = 0;
     bool stripe_pruning_applied = false;
+    size_t next_row_id = 0;
 
     bool row_reader_created = false;
 };
@@ -1285,6 +1286,7 @@ Status 
OrcReader::open(std::shared_ptr<format::FileScanRequest> request) {
         return Status::Uninitialized("OrcReader is not open");
     }
     RETURN_IF_ERROR(format::FileReader::open(std::move(request)));
+    _state->next_row_id = 0;
 
     if (_request->local_positions.empty()) {
         size_t next_position = 0;
@@ -1342,6 +1344,16 @@ Status 
OrcReader::open(std::shared_ptr<format::FileScanRequest> request) {
     _apply_current_stripe_range();
 
     RETURN_IF_ERROR(_create_row_reader());
+    if (_request->row_ids.has_value()) {
+        for (const int64_t row_id : *_request->row_ids) {
+            if (static_cast<uint64_t>(row_id) < 
_state->row_reader_range_first_row ||
+                static_cast<uint64_t>(row_id) >= 
_state->row_reader_range_end_row) {
+                return Status::InvalidArgument(
+                        "ORC row id {} is outside the current split row range 
[{}, {})", row_id,
+                        _state->row_reader_range_first_row, 
_state->row_reader_range_end_row);
+            }
+        }
+    }
     _eof = get_total_rows() == 0;
     return Status::OK();
 }
@@ -1757,7 +1769,12 @@ Status OrcReader::_create_row_reader() {
                 _state->orc_lazy_read_enabled ? _orc_filter.get() : nullptr);
         _state->selected_type = &_state->row_reader->getSelectedType();
         DORIS_CHECK(_state->selected_type->getKind() == 
::orc::TypeKind::STRUCT);
-        _state->batch = 
_state->row_reader->createRowBatch(DEFAULT_ORC_READ_BATCH_SIZE);
+        // Row-id fetch seeks before every read; a one-row batch preserves 
exact selection instead
+        // of also returning the sequential rows that follow the requested 
position.
+        const uint64_t batch_size = _request != nullptr && 
_request->row_ids.has_value()
+                                            ? 1
+                                            : DEFAULT_ORC_READ_BATCH_SIZE;
+        _state->batch = _state->row_reader->createRowBatch(batch_size);
         _state->orc_lazy_selection_valid = false;
         _state->orc_lazy_selected_rows.clear();
         _state->orc_lazy_input_rows = 0;
@@ -2054,11 +2071,23 @@ Status OrcReader::get_block(Block* file_block, size_t* 
rows, bool* eof) {
     }
 
     bool has_next = false;
+    std::optional<uint64_t> fetched_row_id;
     while (true) {
         try {
+            if (_request->row_ids.has_value()) {
+                if (_state->next_row_id >= _request->row_ids->size()) {
+                    _eof = true;
+                    *eof = true;
+                    return Status::OK();
+                }
+                fetched_row_id = 
static_cast<uint64_t>((*_request->row_ids)[_state->next_row_id]);
+                _state->row_reader->seekToRow(*fetched_row_id);
+            }
             // Condition-cache seeks can perform I/O, so keep them in the same 
cancellation
             // boundary as next().
-            _skip_condition_cache_false_granules(rows, eof);
+            if (!_request->row_ids.has_value()) {
+                _skip_condition_cache_false_granules(rows, eof);
+            }
             if (*eof) {
                 return Status::OK();
             }
@@ -2066,6 +2095,9 @@ Status OrcReader::get_block(Block* file_block, size_t* 
rows, bool* eof) {
             _state->orc_lazy_selected_rows.clear();
             _state->orc_lazy_input_rows = 0;
             has_next = _state->row_reader->next(*_state->batch);
+            if (_request->row_ids.has_value() && has_next) {
+                ++_state->next_row_id;
+            }
         } catch (const std::exception& e) {
             if (is_orc_stop(_io_ctx.get(), e)) {
                 file_block->clear_column_data(file_block->columns());
@@ -2086,6 +2118,10 @@ Status OrcReader::get_block(Block* file_block, size_t* 
rows, bool* eof) {
             }
             break;
         }
+        if (_request->row_ids.has_value()) {
+            return Status::InternalError("ORC row id {} could not be read from 
the current split",
+                                         *fetched_row_id);
+        }
         bool advanced = false;
         RETURN_IF_ERROR(_advance_to_next_stripe_range(&advanced));
         if (!advanced) {
@@ -2096,7 +2132,7 @@ Status OrcReader::get_block(Block* file_block, size_t* 
rows, bool* eof) {
     }
 
     const auto batch_rows = static_cast<size_t>(_state->batch->numElements);
-    const auto batch_first_row = _state->row_reader->getRowNumber();
+    const auto batch_first_row = 
fetched_row_id.value_or(_state->row_reader->getRowNumber());
     _state->current_batch_first_row = batch_first_row;
     _state->condition_cache_next_row = _state->current_batch_first_row + 
batch_rows;
     auto* struct_batch = 
dynamic_cast<::orc::StructVectorBatch*>(_state->batch.get());
diff --git a/be/src/format_v2/orc/orc_reader.h 
b/be/src/format_v2/orc/orc_reader.h
index 0c42b32f617..5994ad6993c 100644
--- a/be/src/format_v2/orc/orc_reader.h
+++ b/be/src/format_v2/orc/orc_reader.h
@@ -77,6 +77,7 @@ public:
     Status get_schema(std::vector<format::ColumnDefinition>* const 
file_schema) const override;
     std::unique_ptr<format::TableColumnMapper> create_column_mapper(
             format::TableColumnMapperOptions options) const override;
+    bool supports_rowid_fetch() const override { return true; }
     Status open(std::shared_ptr<format::FileScanRequest> request) override;
     Status get_block(Block* file_block, size_t* rows, bool* eof) override;
     Status get_aggregate_result(const format::FileAggregateRequest& request,
diff --git a/be/src/format_v2/parquet/parquet_reader.h 
b/be/src/format_v2/parquet/parquet_reader.h
index b912af0a165..1b162b48bd6 100644
--- a/be/src/format_v2/parquet/parquet_reader.h
+++ b/be/src/format_v2/parquet/parquet_reader.h
@@ -62,6 +62,7 @@ public:
 
     std::unique_ptr<format::TableColumnMapper> create_column_mapper(
             format::TableColumnMapperOptions options) const override;
+    bool supports_rowid_fetch() const override { return true; }
 
     Status open(std::shared_ptr<format::FileScanRequest> request) override;
 
diff --git a/be/src/format_v2/parquet/parquet_scan.cpp 
b/be/src/format_v2/parquet/parquet_scan.cpp
index 2652e3e5f75..254e43bbc9b 100644
--- a/be/src/format_v2/parquet/parquet_scan.cpp
+++ b/be/src/format_v2/parquet/parquet_scan.cpp
@@ -842,7 +842,28 @@ Status build_native_row_group_read_plans(
         row_group_plan.row_group_id = row_group_idx;
         row_group_plan.first_file_row = row_group_first_rows[row_group_idx];
         row_group_plan.row_group_rows = row_group.num_rows;
-        row_group_plan.selected_ranges = {{.start = 0, .length = 
row_group.num_rows}};
+        if (request.row_ids.has_value()) {
+            const auto& row_ids = *request.row_ids;
+            const int64_t row_group_end = row_group_plan.first_file_row + 
row_group.num_rows;
+            auto row_id = std::ranges::lower_bound(row_ids, 
row_group_plan.first_file_row);
+            const auto row_id_end = std::ranges::lower_bound(row_id, 
row_ids.end(), row_group_end);
+            for (; row_id != row_id_end; ++row_id) {
+                const int64_t local_row = *row_id - 
row_group_plan.first_file_row;
+                if (!row_group_plan.selected_ranges.empty() &&
+                    row_group_plan.selected_ranges.back().start +
+                                    
row_group_plan.selected_ranges.back().length ==
+                            local_row) {
+                    ++row_group_plan.selected_ranges.back().length;
+                } else {
+                    row_group_plan.selected_ranges.push_back({.start = 
local_row, .length = 1});
+                }
+            }
+            if (row_group_plan.selected_ranges.empty()) {
+                continue;
+            }
+        } else {
+            row_group_plan.selected_ranges = {{.start = 0, .length = 
row_group.num_rows}};
+        }
         row_group_plan.expensive_pruning_pending = true;
         prepare_row_group_physical_projection(row_group, file_schema, request, 
&row_group_plan);
         plan->row_groups.push_back(std::move(row_group_plan));
@@ -1580,14 +1601,14 @@ Status ParquetScanScheduler::open_next_row_group(
     RETURN_IF_ERROR(detail::build_native_prefetch_ranges(
             thrift_metadata, file_schema, 
request_scan_columns(row_group_request), row_group_idx,
             file_context.native_file->size(), compat.parquet_816_padding, 
&native_ranges));
-    if (request.non_predicate_positions.empty()) {
+    if (!request.row_ids.has_value() && 
request.non_predicate_positions.empty()) {
         _current_merge_range_active = 
file_context.set_native_random_access_ranges(
                 native_ranges, 
detail::average_prefetch_range_size(native_ranges), _profile,
                 _merge_read_slice_size);
     } else {
-        // Independent predicate/output readers may revisit the same physical 
leaf at different
-        // cursors. MergeRangeFileReader has one consumptive cache per range, 
so use the random
-        // access reader for this layout instead of sharing one sequential 
range cache.
+        // Row-ID reads must not merge whole chunks containing unselected 
rows. Independent
+        // predicate/output readers also need random access: they can revisit 
one physical leaf
+        // at different cursors, while MergeRangeFileReader has one 
consumptive cache per range.
         _current_merge_range_active = 
file_context.set_native_random_access_ranges(
                 {}, 0, _profile, _merge_read_slice_size);
     }
@@ -1621,7 +1642,7 @@ Status ParquetScanScheduler::open_next_row_group(
                 file_context.native_io_ctx, _runtime_state, 
file_context.native_page_cache_enabled,
                 file_context.native_page_cache_file_key,
                 _current_dictionary_filters.contains(local_id), 
_scan_profile.column_reader_profile,
-                &column_reader));
+                &column_reader, !request.row_ids.has_value()));
         _current_predicate_columns[local_id] = std::move(column_reader);
     }
     // Start warming filter-column chunks as soon as their row group is 
selected. The native
@@ -1630,8 +1651,9 @@ Status ParquetScanScheduler::open_next_row_group(
     if (!_current_merge_range_active) {
         const auto prefetch_columns =
                 adaptive_predicate_prefetch_columns(request, 
row_group_request.predicate_columns);
-        RETURN_IF_ERROR(prefetch_current_row_group_columns(
-                file_context, file_schema, prefetch_columns, 
&_current_predicate_prefetched));
+        RETURN_IF_ERROR(prefetch_current_row_group_columns(file_context, 
file_schema, request,
+                                                           prefetch_columns,
+                                                           
&_current_predicate_prefetched));
     }
     for (const auto& col : row_group_request.non_predicate_columns) {
         const auto local_id = col.column_id();
@@ -1660,7 +1682,7 @@ Status ParquetScanScheduler::open_next_row_group(
                 row_group_idx, _current_selected_ranges, 
_current_offset_indexes, _timezone,
                 file_context.native_io_ctx, _runtime_state, 
file_context.native_page_cache_enabled,
                 file_context.native_page_cache_file_key, false, 
_scan_profile.column_reader_profile,
-                &column_reader));
+                &column_reader, !request.row_ids.has_value()));
         _current_non_predicate_columns[local_id] = std::move(column_reader);
     }
     if (!_current_merge_range_active &&
@@ -1670,7 +1692,8 @@ Status ParquetScanScheduler::open_next_row_group(
         // output chunks immediately after their readers are created. Filtered 
scans still defer
         // this until at least one row survives the predicate phase.
         RETURN_IF_ERROR(prefetch_current_row_group_columns(
-                file_context, file_schema, 
physical_non_predicate_columns(row_group_request),
+                file_context, file_schema, request,
+                physical_non_predicate_columns(row_group_request),
                 &_current_non_predicate_prefetched));
     }
     if (_parquet_profile != nullptr) {
@@ -2206,7 +2229,7 @@ Status 
ParquetScanScheduler::prepare_current_dictionary_filters(
                 row_group_idx, _current_selected_ranges, 
_current_offset_indexes, _timezone,
                 file_context.native_io_ctx, _runtime_state, 
file_context.native_page_cache_enabled,
                 file_context.native_page_cache_file_key, true, 
_scan_profile.column_reader_profile,
-                &column_reader));
+                &column_reader, !request.row_ids.has_value()));
         MutableColumnPtr dictionary_values;
         {
             SCOPED_TIMER(_scan_profile.dict_filter_read_dict_time);
@@ -2898,10 +2921,14 @@ Status 
ParquetScanScheduler::read_filter_columns(int64_t batch_rows,
 Status ParquetScanScheduler::prefetch_current_row_group_columns(
         ParquetFileContext& file_context,
         const std::vector<std::unique_ptr<ParquetColumnSchema>>& file_schema,
+        const format::FileScanRequest& request,
         const std::vector<format::LocalColumnIndex>& scan_columns, bool* 
prefetched) {
     DORIS_CHECK(prefetched != nullptr);
-    if (_current_merge_range_active || *prefetched || scan_columns.empty() ||
-        _current_row_group_id < 0 || file_context.native_metadata == nullptr) {
+    // Row-ID requests remain selective even without conjuncts. Whole-chunk 
dry-run prefetch
+    // would download unselected bytes without query accounting; demand reads 
retain IOContext stats.
+    if (request.row_ids.has_value() || _current_merge_range_active || 
*prefetched ||
+        scan_columns.empty() || _current_row_group_id < 0 ||
+        file_context.native_metadata == nullptr) {
         return Status::OK();
     }
     *prefetched = true;
@@ -3022,9 +3049,10 @@ Status 
ParquetScanScheduler::read_current_row_group_batch(
         // materializing non-predicate columns, so fully filtered batches 
avoid unnecessary IO.
         const auto& physical_request =
                 _current_row_group_request != nullptr ? 
*_current_row_group_request : request;
-        RETURN_IF_ERROR(prefetch_current_row_group_columns(
-                file_context, file_schema, 
physical_non_predicate_columns(physical_request),
-                &_current_non_predicate_prefetched));
+        RETURN_IF_ERROR(
+                prefetch_current_row_group_columns(file_context, file_schema, 
request,
+                                                   
physical_non_predicate_columns(physical_request),
+                                                   
&_current_non_predicate_prefetched));
     }
 
     if (selected_rows > _batch_size) {
@@ -3185,6 +3213,13 @@ Status ParquetScanScheduler::read_next_batch(
         *eof = false;
         return Status::OK();
     }
+    // Phase-two IDs have already survived filtering. Append sparse ranges 
directly to one
+    // output block so TableReader finalization and result merging happen once 
per caller batch.
+    // Filtered requests and pending projection changes retain their 
single-batch coordinates.
+    const bool append_row_id_ranges =
+            _active_request->row_ids.has_value() && 
_active_request->predicate_columns.empty() &&
+            _active_request->conjuncts.empty() && 
_active_request->delete_conjuncts.empty() &&
+            _active_request->count_star_placeholder_columns.empty() && 
_pending_request == nullptr;
     int64_t predicate_batch_rows = std::max(_batch_size, 
_empty_predicate_batch_rows);
     const int64_t max_predicate_batch_rows = std::min<int64_t>(
             std::numeric_limits<uint16_t>::max(),
@@ -3237,13 +3272,17 @@ Status ParquetScanScheduler::read_next_batch(
             continue;
         }
 
-        const int64_t batch_rows = std::min<int64_t>(predicate_batch_rows, 
remaining_rows);
+        const int64_t row_cap = append_row_id_ranges ? _batch_size - 
static_cast<int64_t>(*rows)
+                                                     : predicate_batch_rows;
+        const int64_t batch_rows = std::min<int64_t>(row_cap, remaining_rows);
         const int64_t physical_rows_read = batch_rows;
         const int64_t batch_first_file_row =
                 _current_row_group_first_row + _current_row_group_rows_read;
+        size_t batch_output_rows = 0;
         RETURN_IF_ERROR(read_current_row_group_batch(file_context, 
file_schema, batch_rows,
                                                      *_active_request, 
batch_first_file_row,
-                                                     file_block, rows));
+                                                     file_block, 
&batch_output_rows));
+        *rows += batch_output_rows;
         _current_row_group_rows_read += physical_rows_read;
         _current_range_rows_read += physical_rows_read;
         if (_current_range_rows_read >= current_range.length) {
@@ -3259,6 +3298,9 @@ Status ParquetScanScheduler::read_next_batch(
             _publish_adaptive_state(*_active_request);
             continue;
         }
+        if (append_row_id_ranges && *rows < static_cast<size_t>(_batch_size)) {
+            continue;
+        }
         *eof = false;
         return Status::OK();
     }
diff --git a/be/src/format_v2/parquet/parquet_scan.h 
b/be/src/format_v2/parquet/parquet_scan.h
index dff822fe21c..7046d342304 100644
--- a/be/src/format_v2/parquet/parquet_scan.h
+++ b/be/src/format_v2/parquet/parquet_scan.h
@@ -313,6 +313,7 @@ private:
     Status prefetch_current_row_group_columns(
             ParquetFileContext& file_context,
             const std::vector<std::unique_ptr<ParquetColumnSchema>>& 
file_schema,
+            const format::FileScanRequest& request,
             const std::vector<format::LocalColumnIndex>& scan_columns, bool* 
prefetched);
 
     Status read_current_row_group_batch(
diff --git a/be/src/format_v2/parquet/reader/native_column_reader.cpp 
b/be/src/format_v2/parquet/reader/native_column_reader.cpp
index 273964e5d1f..e0fab04951b 100644
--- a/be/src/format_v2/parquet/reader/native_column_reader.cpp
+++ b/be/src/format_v2/parquet/reader/native_column_reader.cpp
@@ -266,7 +266,7 @@ Status NativeColumnReader::create(
         const cctz::time_zone* timezone, io::IOContext* io_ctx, RuntimeState* 
runtime_state,
         bool enable_page_cache, const std::string& page_cache_file_key,
         bool enable_dictionary_filter, ParquetColumnReaderProfile profile,
-        std::unique_ptr<ParquetColumnReader>* reader) {
+        std::unique_ptr<ParquetColumnReader>* reader, bool enable_read_ahead) {
     if (reader == nullptr) {
         return Status::InvalidArgument("Native parquet reader result is null");
     }
@@ -307,10 +307,11 @@ Status NativeColumnReader::create(
     auto native_reader = std::unique_ptr<NativeColumnReader>(
             new NativeColumnReader(column_schema, std::move(logical_type), 
std::move(native_type),
                                    std::move(variant_plan), profile));
-    RETURN_IF_ERROR(native_reader->init(
-            std::move(file), metadata, row_group_id, field, 
std::move(schema_node),
-            std::move(projected_ids), selected_ranges, offset_indexes, 
timezone, io_ctx,
-            runtime_state, enable_page_cache, page_cache_file_key, 
enable_dictionary_filter));
+    RETURN_IF_ERROR(native_reader->init(std::move(file), metadata, 
row_group_id, field,
+                                        std::move(schema_node), 
std::move(projected_ids),
+                                        selected_ranges, offset_indexes, 
timezone, io_ctx,
+                                        runtime_state, enable_page_cache, 
page_cache_file_key,
+                                        enable_dictionary_filter, 
enable_read_ahead));
     *reader = std::move(native_reader);
     return Status::OK();
 }
@@ -322,7 +323,7 @@ Status NativeColumnReader::init(
         const std::unordered_map<int, tparquet::OffsetIndex>& offset_indexes,
         const cctz::time_zone* timezone, io::IOContext* io_ctx, RuntimeState* 
runtime_state,
         bool enable_page_cache, const std::string& page_cache_file_key,
-        bool enable_dictionary_filter) {
+        bool enable_dictionary_filter, bool enable_read_ahead) {
     DORIS_CHECK(file != nullptr);
     DORIS_CHECK(metadata != nullptr);
     DORIS_CHECK(field != nullptr);
@@ -347,7 +348,10 @@ Status NativeColumnReader::init(
 
     const size_t max_group_buffer = config::parquet_rowgroup_max_buffer_mb << 
20;
     const size_t max_column_buffer = config::parquet_column_max_buffer_mb << 
20;
-    const size_t max_buffer_size = std::min(max_group_buffer, 
max_column_buffer);
+    // Sparse exact-row fetches need demand pages, not one read-ahead buffer 
per physical leaf.
+    // Passing zero through the native tree also prevents wide nested 
projections multiplying it.
+    const size_t max_buffer_size =
+            enable_read_ahead ? std::min(max_group_buffer, max_column_buffer) 
: 0;
     RuntimeState* native_runtime_state = runtime_state;
     const bool runtime_page_cache_enabled =
             runtime_state == nullptr ||
diff --git a/be/src/format_v2/parquet/reader/native_column_reader.h 
b/be/src/format_v2/parquet/reader/native_column_reader.h
index 150ab4352d0..69588484696 100644
--- a/be/src/format_v2/parquet/reader/native_column_reader.h
+++ b/be/src/format_v2/parquet/reader/native_column_reader.h
@@ -76,7 +76,8 @@ public:
                          RuntimeState* runtime_state, bool enable_page_cache,
                          const std::string& page_cache_file_key, bool 
enable_dictionary_filter,
                          ParquetColumnReaderProfile profile,
-                         std::unique_ptr<ParquetColumnReader>* reader);
+                         std::unique_ptr<ParquetColumnReader>* reader,
+                         bool enable_read_ahead = true);
 
     ~NativeColumnReader() override;
 
@@ -115,7 +116,7 @@ private:
                 const std::unordered_map<int, tparquet::OffsetIndex>& 
offset_indexes,
                 const cctz::time_zone* timezone, io::IOContext* io_ctx, 
RuntimeState* runtime_state,
                 bool enable_page_cache, const std::string& page_cache_file_key,
-                bool enable_dictionary_filter);
+                bool enable_dictionary_filter, bool enable_read_ahead);
 
     Status read_with_filter(int64_t rows, const uint8_t* filter_data, bool 
filter_all,
                             MutableColumnPtr& column, const DataTypePtr& 
output_type,
diff --git a/be/src/format_v2/table_reader.cpp 
b/be/src/format_v2/table_reader.cpp
index 8e03d3d2282..2c7ffe933c7 100644
--- a/be/src/format_v2/table_reader.cpp
+++ b/be/src/format_v2/table_reader.cpp
@@ -1343,6 +1343,7 @@ Status TableReader::refresh_conjuncts(VExprContextSPtrs 
conjuncts,
             _table_filters, _projected_columns, refreshed_request.get(), 
_runtime_state,
             _file_scan_request == nullptr ? nullptr : 
&_file_scan_request->local_positions));
     refreshed_request->predicate_snapshot_digest = _predicate_snapshot_digest;
+    refreshed_request->row_ids = _row_ids;
     // A refresh does not prove that every future runtime filter has arrived. 
Keep carrier values
     // available whenever the split started with pending filters.
     if (_push_down_agg_type == TPushAggOp::type::COUNT && 
_push_down_count_columns.has_value() &&
@@ -1751,6 +1752,7 @@ Status TableReader::prepare_split(const SplitReadOptions& 
options) {
                                      ? 
std::make_optional(options.current_range.load_id)
                                      : std::nullopt;
     _global_rowid_context = options.global_rowid_context;
+    _row_ids = options.row_ids;
     _delete_rows = nullptr;
     _deletion_vector = nullptr;
     _aggregate_pushdown_tried = false;
@@ -1779,9 +1781,10 @@ Status TableReader::prepare_split(const 
SplitReadOptions& options) {
     // the NULL state of a COUNT argument. Require the new FE's explicit empty 
argument list, which
     // means COUNT(*)/COUNT(1). A non-empty list means COUNT(col), while 
nullopt comes from an old FE
     // whose COUNT semantics are unknown during a BE-first rolling upgrade.
-    if (_push_down_agg_type == TPushAggOp::type::COUNT && 
_push_down_count_columns.has_value() &&
-        _push_down_count_columns->empty() && 
options.all_runtime_filters_applied &&
-        _conjuncts.empty() && 
options.current_range.__isset.table_format_params &&
+    if (!_row_ids.has_value() && _push_down_agg_type == 
TPushAggOp::type::COUNT &&
+        _push_down_count_columns.has_value() && 
_push_down_count_columns->empty() &&
+        options.all_runtime_filters_applied && _conjuncts.empty() &&
+        options.current_range.__isset.table_format_params &&
         
options.current_range.table_format_params.__isset.table_level_row_count) {
         
DORIS_CHECK(options.current_range.table_format_params.table_level_row_count >= 
-1);
         _remaining_table_level_count =
diff --git a/be/src/format_v2/table_reader.h b/be/src/format_v2/table_reader.h
index 797bba2a0a1..273ef982021 100644
--- a/be/src/format_v2/table_reader.h
+++ b/be/src/format_v2/table_reader.h
@@ -194,6 +194,9 @@ struct SplitReadOptions {
     int64_t format_split_id = -1;
     int64_t format_split_id_end = -1;
     std::optional<GlobalRowIdContext> global_rowid_context;
+    // Optional absolute file-row selection used by TopN two-phase 
materialization. TableReader
+    // carries it unchanged into the format-neutral FileScanRequest.
+    std::optional<std::vector<int64_t>> row_ids = std::nullopt;
 };
 
 // Base class for table-level readers.
@@ -478,6 +481,7 @@ protected:
         RETURN_IF_ERROR(_data_reader.column_mapper->create_scan_request(
                 _table_filters, _projected_columns, file_request.get(), 
_runtime_state));
         file_request->predicate_snapshot_digest = _predicate_snapshot_digest;
+        file_request->row_ids = _row_ids;
         _constant_pruning_safe_filter_count =
                 std::min(_constant_pruning_safe_filter_count,
                          
file_request->constant_pruning_safe_table_filter_count);
@@ -1072,7 +1076,7 @@ protected:
         *pushed_down = false;
         block->clear_column_data(_projected_columns.size());
         _aggregate_pushdown_tried = true;
-        if (!_supports_aggregate_pushdown(_push_down_agg_type)) {
+        if (_row_ids.has_value() || 
!_supports_aggregate_pushdown(_push_down_agg_type)) {
             return Status::OK();
         }
 
@@ -2060,6 +2064,7 @@ protected:
     // irreversible aggregate rows, not only the table-level row-count 
shortcut in prepare_split().
     bool _all_runtime_filters_applied_for_split = true;
     std::optional<GlobalRowIdContext> _global_rowid_context;
+    std::optional<std::vector<int64_t>> _row_ids;
     bool _aggregate_pushdown_tried = false;
     std::optional<FileAggregateResult> _metadata_aggregate_result;
     bool _current_split_pruned = false;
diff --git a/be/test/exec/rowid_fetcher_test.cpp 
b/be/test/exec/rowid_fetcher_test.cpp
new file mode 100644
index 00000000000..a1ba94cf702
--- /dev/null
+++ b/be/test/exec/rowid_fetcher_test.cpp
@@ -0,0 +1,243 @@
+// 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 "exec/rowid_fetcher.h"
+
+#include <gtest/gtest.h>
+
+#include <string>
+#include <vector>
+
+#include "common/consts.h"
+#include "exec/operator/file_scan_operator.h"
+#include "exec/scan/file_scanner_v2.h"
+#include "format_v2/column_mapper.h"
+#include "format_v2/table/hive_reader.h"
+#include "runtime/descriptor_helper.h"
+#include "runtime/descriptors.h"
+#include "runtime/runtime_state.h"
+
+namespace doris {
+
+class RowIdStorageReaderTest : public testing::Test {
+protected:
+    struct SlotSpec {
+        std::string col_name = "c";
+        int32_t slot_id = 0;
+        PrimitiveType type = TYPE_INT;
+        int32_t col_unique_id = 1;
+    };
+
+    static SlotDescriptor make_slot(const SlotSpec& spec) {
+        TSlotDescriptor tdesc = TSlotDescriptorBuilder()
+                                        .type(spec.type)
+                                        .nullable(true)
+                                        .column_name(spec.col_name)
+                                        .column_pos(0)
+                                        .build();
+        tdesc.__set_id(spec.slot_id);
+        tdesc.__set_col_unique_id(spec.col_unique_id);
+        return SlotDescriptor(tdesc);
+    }
+};
+
+TEST_F(RowIdStorageReaderTest, ExternalScannerSelectionRespectsRolloutOption) {
+    for (auto format : {TFileFormatType::FORMAT_PARQUET, 
TFileFormatType::FORMAT_ORC}) {
+        TFileScanRangeParams params;
+        params.__set_format_type(format);
+        TFileRangeDesc range;
+        for (const auto& table_format : {"hive", "iceberg", "tvf"}) {
+            TTableFormatFileDesc table;
+            table.__set_table_format_type(table_format);
+            params.__set_table_format_params(table);
+            range.__set_table_format_params(table);
+            for (int option = 0; option < 3; ++option) {
+                TQueryOptions options;
+                if (option != 0) {
+                    options.__set_enable_file_scanner_v2(option == 2);
+                } else {
+                    // An absent Thrift field must not enable V2 even if its 
value defaults to true.
+                    options.enable_file_scanner_v2 = true;
+                    options.__isset.enable_file_scanner_v2 = false;
+                }
+                
EXPECT_EQ(RowIdStorageReader::should_use_file_scanner_v2(options, params, 
range),
+                          
FileScanLocalState::TEST_should_use_file_scanner_v2(options, false,
+                                                                              
params));
+                
EXPECT_EQ(RowIdStorageReader::should_use_file_scanner_v2(options, params, 
range),
+                          option == 2);
+            }
+        }
+    }
+}
+
+TEST_F(RowIdStorageReaderTest, 
ExternalScannerSelectionKeepsUnsupportedFormatsOnV1) {
+    TQueryOptions options;
+    options.__set_enable_file_scanner_v2(true);
+    TFileScanRangeParams params;
+    params.__set_format_type(TFileFormatType::FORMAT_PARQUET);
+    TFileRangeDesc range;
+    range.__set_format_type(TFileFormatType::FORMAT_JNI);
+    EXPECT_FALSE(RowIdStorageReader::should_use_file_scanner_v2(options, 
params, range));
+    range.__set_format_type(TFileFormatType::FORMAT_LANCE);
+    EXPECT_FALSE(RowIdStorageReader::should_use_file_scanner_v2(options, 
params, range));
+    range.__set_format_type(TFileFormatType::FORMAT_ORC);
+    TTableFormatFileDesc table;
+    table.__set_table_format_type("transactional_hive");
+    params.__set_table_format_params(table);
+    range.__set_table_format_params(table);
+    EXPECT_FALSE(RowIdStorageReader::should_use_file_scanner_v2(options, 
params, range));
+}
+
+TEST_F(RowIdStorageReaderTest, ExternalFetchPreservesIcebergFileMetadata) {
+    TFileRangeDesc range;
+    range.__set_path("normalized/data.parquet");
+    TIcebergFileDesc iceberg;
+    iceberg.__set_original_file_path("s3://bucket/data.parquet");
+    iceberg.__set_format_version(3);
+    iceberg.__set_first_row_id(128);
+    iceberg.__set_last_updated_sequence_number(7);
+    TIcebergDeleteFileDesc deletes;
+    deletes.__set_path("s3://bucket/deletes.parquet");
+    iceberg.__set_delete_files({deletes});
+    range.table_format_params.__set_iceberg_params(iceberg);
+
+    const auto fetch_range = 
RowIdStorageReader::build_external_fetch_range(range);
+    const auto& fetch_iceberg = fetch_range.table_format_params.iceberg_params;
+    EXPECT_TRUE(fetch_iceberg.__isset.original_file_path);
+    EXPECT_EQ(fetch_iceberg.original_file_path, iceberg.original_file_path);
+    EXPECT_EQ(fetch_iceberg.format_version, 3);
+    EXPECT_EQ(fetch_iceberg.first_row_id, 128);
+    EXPECT_EQ(fetch_iceberg.last_updated_sequence_number, 7);
+    EXPECT_TRUE(fetch_iceberg.delete_files.empty());
+    EXPECT_EQ(range.table_format_params.iceberg_params, iceberg);
+}
+
+TEST_F(RowIdStorageReaderTest, ExternalFetchPreservesPrunedMetadataCategories) 
{
+    TFileScanRangeParams source_params;
+    // Branch-4.1 recognizes these built-in virtual names; arbitrary 
synthesized names are
+    // not virtual columns and fall through to the legacy partition-slot 
classification.
+    source_params.__set_column_name_to_category(
+            {{BeConsts::GLOBAL_ROWID_COL, TColumnCategory::SYNTHESIZED},
+             {BeConsts::ICEBERG_ROWID_COL, TColumnCategory::SYNTHESIZED},
+             {"generated_col", TColumnCategory::GENERATED},
+             {"partition_col", TColumnCategory::PARTITION_KEY}});
+    // Phase one projects only the sort key; none of these fetch slots 
survives in required_slots.
+    TFileScanSlotInfo sort_slot;
+    sort_slot.__set_slot_id(99);
+    sort_slot.__set_category(TColumnCategory::REGULAR);
+    source_params.__set_required_slots({sort_slot});
+    source_params.__set_column_idxs({0});
+    std::vector<SlotDescriptor> slots;
+    for (const auto& name :
+         {BeConsts::GLOBAL_ROWID_COL, BeConsts::ICEBERG_ROWID_COL, 
std::string("generated_col"),
+          std::string("partition_col"), std::string("value")}) {
+        slots.emplace_back(make_slot(
+                {.col_name = name,
+                 .slot_id = static_cast<int32_t>(slots.size()),
+                 .type = name == BeConsts::GLOBAL_ROWID_COL ? TYPE_STRING : 
TYPE_BIGINT}));
+    }
+    const auto params = RowIdStorageReader::build_external_scan_params(
+            source_params, TFileRangeDesc {}, slots, {3, 4, 1, 2, 0});
+    EXPECT_EQ(params.column_idxs, (std::vector<int32_t> {1, 0}));
+    const std::vector<TColumnCategory::type> categories {
+            TColumnCategory::SYNTHESIZED, TColumnCategory::SYNTHESIZED, 
TColumnCategory::GENERATED,
+            TColumnCategory::PARTITION_KEY, TColumnCategory::REGULAR};
+    for (size_t i = 0; i < slots.size(); ++i) {
+        const auto& info = params.required_slots[i];
+        EXPECT_TRUE(info.__isset.category);
+        EXPECT_EQ(info.category, categories[i]);
+        EXPECT_EQ(info.is_file_slot, i == 2 || i == 4);
+        EXPECT_EQ(FileScannerV2::TEST_is_partition_slot(info, 
slots[i].col_name()), i == 3);
+    }
+    // An authoritative empty map means ordinary physical columns, even for 
metadata spellings.
+    source_params.__set_column_name_to_category({});
+    const auto physical_params = 
RowIdStorageReader::build_external_scan_params(
+            source_params, TFileRangeDesc {}, slots, {3, 4, 1, 2, 0});
+    for (const auto& info : physical_params.required_slots) {
+        EXPECT_EQ(info.category, TColumnCategory::REGULAR);
+        EXPECT_TRUE(info.is_file_slot);
+    }
+}
+
+// Row-id fetch rebuilds the projection after TopN. Hive's positional mapper 
must consume
+// indexes only for physical columns, including when partition columns precede 
file columns.
+TEST_F(RowIdStorageReaderTest, 
ExternalFetchPartitionSlotsPreserveHivePositionMapping) {
+    for (const auto format : {TFileFormatType::FORMAT_ORC, 
TFileFormatType::FORMAT_PARQUET}) {
+        TQueryOptions options;
+        options.__set_hive_orc_use_column_names(false);
+        options.__set_hive_parquet_use_column_names(false);
+        RuntimeState state(options, TQueryGlobals {});
+        TFileScanRangeParams source_params;
+        source_params.__set_format_type(format);
+        source_params.__set_column_idxs({0, 1, 2});
+        TFileScanSlotInfo old_slot;
+        old_slot.__set_slot_id(99);
+        source_params.__set_required_slots({old_slot});
+        source_params.__set_slot_name_to_schema_pos({{"old_column", 0}});
+        TFileRangeDesc range;
+        range.__set_columns_from_path_keys({"partition_col"});
+
+        for (const auto& names : {std::vector<std::string> {"value", 
"partition_col"},
+                                  std::vector<std::string> {"partition_col", 
"value", "id"},
+                                  std::vector<std::string> {"partition_col"},
+                                  std::vector<std::string> {"value", "id"}}) {
+            SCOPED_TRACE(fmt::format("format={}, columns={}", 
static_cast<int>(format),
+                                     fmt::join(names, ",")));
+            std::vector<SlotDescriptor> slots;
+            std::vector<uint32_t> indices;
+            std::vector<int32_t> file_indices;
+            for (const auto& name : names) {
+                slots.emplace_back(make_slot(
+                        {.col_name = name, .slot_id = 
static_cast<int32_t>(slots.size())}));
+                const uint32_t index = name == "partition_col" ? 3 : name == 
"value" ? 2 : 0;
+                indices.emplace_back(index);
+                if (name != "partition_col") {
+                    file_indices.emplace_back(index);
+                }
+            }
+            const auto params = 
RowIdStorageReader::build_external_scan_params(source_params, range,
+                                                                               
slots, indices);
+            ASSERT_EQ(params.required_slots.size(), slots.size());
+            EXPECT_EQ(params.column_idxs, file_indices);
+            
EXPECT_FALSE(params.slot_name_to_schema_pos.contains("old_column"));
+            format::ProjectedColumnBuildContext context {
+                    .scan_params = &params, .range = &range, .runtime_state = 
&state};
+            format::hive::HiveReader reader;
+            for (size_t i = 0; i < slots.size(); ++i) {
+                const auto& slot_info = params.required_slots[i];
+                const auto& name = names[i];
+                const bool is_partition = name == "partition_col";
+                EXPECT_TRUE(slot_info.__isset.slot_id);
+                EXPECT_EQ(slot_info.slot_id, slots[i].id());
+                EXPECT_TRUE(slot_info.__isset.is_file_slot);
+                EXPECT_EQ(FileScannerV2::TEST_is_partition_slot(slot_info, 
name), is_partition);
+                format::ColumnDefinition column;
+                column.name = name;
+                column.type = slots[i].get_data_type_ptr();
+                const auto status = 
reader.annotate_projected_column(slot_info, &context, &column);
+                ASSERT_TRUE(status.ok()) << status;
+                if (!is_partition) {
+                    EXPECT_EQ(column.get_identifier_position(), indices[i]);
+                }
+            }
+            EXPECT_EQ(context.next_file_column_idx, file_indices.size());
+            EXPECT_TRUE(reader.validate_projected_columns(context).ok());
+        }
+    }
+}
+
+} // namespace doris
diff --git a/be/test/format_v2/orc/orc_reader_test.cpp 
b/be/test/format_v2/orc/orc_reader_test.cpp
index 1884afaf73c..10f3a524aeb 100644
--- a/be/test/format_v2/orc/orc_reader_test.cpp
+++ b/be/test/format_v2/orc/orc_reader_test.cpp
@@ -10079,6 +10079,38 @@ TEST_F(NewOrcReaderTest, CloseClearsFileLocalState) {
     EXPECT_FALSE(reader->open(request).ok());
 }
 
+TEST_F(NewOrcReaderTest, ReadsOnlyRequestedAbsoluteFileRows) {
+    auto reader = create_reader();
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ASSERT_TRUE(reader->init(&state).ok());
+
+    std::vector<format::ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    auto request = std::make_shared<format::FileScanRequest>();
+    request->non_predicate_columns = {field_projection(0)};
+    request->row_ids = {0, 2, 4};
+    ASSERT_TRUE(reader->open(request).ok());
+
+    std::vector<int32_t> ids;
+    bool eof = false;
+    while (!eof) {
+        Block block = build_file_block({schema[0]});
+        size_t rows = 0;
+        ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+        if (rows == 0) {
+            continue;
+        }
+        const auto& id_column = assert_cast<const ColumnInt32&>(
+                assert_cast<const 
ColumnNullable&>(*block.get_by_position(0).column)
+                        .get_nested_column());
+        for (size_t row = 0; row < rows; ++row) {
+            ids.push_back(id_column.get_element(row));
+        }
+    }
+
+    EXPECT_EQ(ids, std::vector<int32_t>({1, 3, 5}));
+}
+
 TEST_F(NewOrcReaderTest, ReadPrimitiveTypesWithNulls) {
     const auto primitive_file_path = (_test_dir / "primitive.orc").string();
     write_primitive_orc_file(primitive_file_path);
diff --git a/be/test/format_v2/parquet/parquet_scan_test.cpp 
b/be/test/format_v2/parquet/parquet_scan_test.cpp
index 1292b90cb49..c045683e424 100644
--- a/be/test/format_v2/parquet/parquet_scan_test.cpp
+++ b/be/test/format_v2/parquet/parquet_scan_test.cpp
@@ -25,7 +25,10 @@
 #include <parquet/arrow/writer.h>
 #include <parquet/encoding.h>
 
+#include <atomic>
 #include <bit>
+#include <chrono>
+#include <cstdlib>
 #include <cstring>
 #include <filesystem>
 #include <fstream>
@@ -35,6 +38,7 @@
 #include <optional>
 #include <set>
 #include <string>
+#include <thread>
 #include <utility>
 #include <vector>
 
@@ -50,6 +54,7 @@
 #include "core/data_type/data_type_string.h"
 #include "core/data_type/data_type_struct.h"
 #include "core/field.h"
+#include "exec/scan/file_scanner_v2.h"
 #include "exprs/bloom_filter_func.h"
 #include "exprs/create_predicate_function.h"
 #include "exprs/runtime_filter_expr.h"
@@ -69,16 +74,25 @@
 #include "format_v2/parquet/parquet_column_schema.h"
 #include "format_v2/parquet/parquet_reader.h"
 #include "format_v2/parquet/reader/native/block_split_bloom_filter.h"
+#include "format_v2/parquet/reader/native/column_reader.h"
 #include "format_v2/parquet/reader/native_column_reader.h"
 #include "gen_cpp/PlanNodes_types.h"
 #include "gen_cpp/Types_types.h"
+#include "io/cache/block_file_cache.h"
+#include "io/cache/block_file_cache_factory.h"
+#include "io/cache/cached_remote_file_reader.h"
+#include "io/cache/fs_file_cache_storage.h"
+#include "io/fs/local_file_system.h"
 #include "io/io_common.h"
+#include "runtime/descriptor_helper.h"
 #include "runtime/runtime_state.h"
 #include "storage/index/zone_map/zonemap_eval_context.h"
 #include "storage/index/zone_map/zonemap_filter_result.h"
 #include "storage/utils.h"
 #include "testutil/mock/mock_query_context.h"
 #include "util/coding.h"
+#include "util/defer_op.h"
+#include "util/threadpool.h"
 #include "util/thrift_util.h"
 
 namespace doris {
@@ -2596,6 +2610,381 @@ TEST_F(ParquetScanTest, 
GlobalRowIdUsesFileLocalPositionForScanRange) {
     EXPECT_EQ(row_ids, std::vector<uint32_t>({2, 3}));
 }
 
+TEST_F(ParquetScanTest, ReadsOnlyRequestedAbsoluteFileRowsAcrossRowGroups) {
+    write_int_pair_parquet_file(_file_path, 2);
+    auto reader = create_reader();
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ASSERT_TRUE(reader->init(&state).ok());
+
+    std::vector<format::ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    auto request = std::make_shared<format::FileScanRequest>();
+    request->non_predicate_columns = {field_projection(0)};
+    request->row_ids = {0, 3, 5};
+    ASSERT_TRUE(reader->open(request).ok());
+
+    std::vector<int32_t> ids;
+    bool eof = false;
+    while (!eof) {
+        Block block = build_file_block({schema[0]});
+        size_t rows = 0;
+        ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+        if (rows == 0) {
+            continue;
+        }
+        const auto& id_column = 
int32_data_column(*block.get_by_position(0).column);
+        for (size_t row = 0; row < rows; ++row) {
+            ids.push_back(id_column.get_element(row));
+        }
+    }
+
+    EXPECT_EQ(ids, std::vector<int32_t>({1, 4, 6}));
+}
+
+TEST_F(ParquetScanTest, StandaloneRowIdFetchRejectsMissingRows) {
+    write_int_pair_parquet_file(_file_path, 2);
+    TDescriptorTableBuilder descriptors_builder;
+    TTupleDescriptorBuilder tuple_builder;
+    tuple_builder.add_slot(TSlotDescriptorBuilder()
+                                   .type(TYPE_INT)
+                                   .nullable(false)
+                                   .column_name("id")
+                                   .column_pos(0)
+                                   .build());
+    tuple_builder.build(&descriptors_builder);
+    ObjectPool pool;
+    DescriptorTbl* descriptors = nullptr;
+    ASSERT_TRUE(DescriptorTbl::create(&pool, descriptors_builder.desc_tbl(), 
&descriptors).ok());
+    auto* tuple = descriptors->get_tuple_descriptor(0);
+    TFileScanRangeParams params;
+    params.__set_format_type(TFileFormatType::FORMAT_PARQUET);
+    params.__set_file_type(TFileType::FILE_LOCAL);
+    params.__set_src_tuple_id(0);
+    params.__set_dest_tuple_id(0);
+    TFileScanSlotInfo slot;
+    slot.__set_slot_id(0);
+    slot.__set_is_file_slot(true);
+    slot.__set_category(TColumnCategory::REGULAR);
+    params.__set_required_slots({slot});
+    params.__set_column_idxs({0});
+    params.__set_slot_name_to_schema_pos({{"id", 0}});
+    TFileRangeDesc range;
+    range.__set_path(_file_path);
+    range.__set_start_offset(0);
+    range.__set_size(std::filesystem::file_size(_file_path));
+    range.__set_file_size(range.size);
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    state.set_desc_tbl(descriptors);
+    // EOF is valid for a scan, but exact fetches must not expose missing rows 
to reordering.
+    for (const auto& requested : {std::list<int64_t> {}, {0, 3, 5}, {0, 3, 6}, 
{6}}) {
+        RuntimeProfile profile("row_id_fetch");
+        FileScannerV2 scanner(&state, &profile, &params, nullptr, tuple);
+        Block block;
+        block.insert({ColumnInt32::create(), 
std::make_shared<DataTypeInt32>(), "id"});
+        int64_t init_ms = 0;
+        int64_t read_ms = 0;
+        auto status = scanner.read_by_rows(range, requested, &block, &init_ms, 
&read_ms);
+        if (requested.empty() || requested.back() < 6) {
+            ASSERT_TRUE(status.ok()) << status.to_string();
+            ASSERT_EQ(block.rows(), requested.size());
+            const auto& values = 
int32_data_column(*block.get_by_position(0).column);
+            size_t row = 0;
+            for (auto id : requested) {
+                EXPECT_EQ(values.get_element(row++), id + 1);
+            }
+        } else {
+            EXPECT_FALSE(status.ok());
+            EXPECT_NE(status.to_string().find("row-ID fetch"), 
std::string::npos);
+        }
+    }
+}
+
+TEST_F(ParquetScanTest, SparseRowIdsFillBatchesAcrossRangesAndRowGroups) {
+    std::vector<int32_t> ids(4096);
+    std::iota(ids.begin(), ids.end(), 0);
+    auto table = arrow::Table::Make(arrow::schema({arrow::field("id", 
arrow::int32(), false)}),
+                                    {build_int32_array(ids)});
+    write_table(_file_path, table, 500);
+    auto reader = create_reader();
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ASSERT_TRUE(reader->init(&state).ok());
+    reader->set_batch_size(128);
+    std::vector<format::ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    auto request = std::make_shared<format::FileScanRequest>();
+    request->non_predicate_columns = {field_projection(0),
+                                      
field_projection(format::ROW_POSITION_COLUMN_ID)};
+    request->local_positions.emplace(format::LocalColumnId(0), 
format::LocalIndex(0));
+    
request->local_positions.emplace(format::LocalColumnId(format::ROW_POSITION_COLUMN_ID),
+                                     format::LocalIndex(1));
+    request->row_ids.emplace();
+    for (int64_t id = 0; id < 4096; id += 4) {
+        request->row_ids->push_back(id);
+    }
+    ASSERT_TRUE(reader->open(request).ok());
+    size_t fetched = 0;
+    size_t batches = 0;
+    bool eof = false;
+    while (!eof) {
+        Block block = build_file_block({schema[0], 
format::row_position_column_definition()});
+        size_t rows = 0;
+        ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+        if (rows == 0) {
+            continue;
+        }
+        EXPECT_LE(rows, 128);
+        const auto& column = 
int32_data_column(*block.get_by_position(0).column);
+        const auto& positions = 
int64_data_column(*block.get_by_position(1).column);
+        for (size_t row = 0; row < rows; ++row) {
+            ASSERT_LT(fetched, request->row_ids->size());
+            EXPECT_EQ(positions.get_element(row), 
(*request->row_ids)[fetched]);
+            EXPECT_EQ(column.get_element(row), (*request->row_ids)[fetched++]);
+        }
+        ++batches;
+    }
+    RecordProperty("nonempty_batches", std::to_string(batches));
+    EXPECT_EQ(fetched, 1024);
+    EXPECT_EQ(batches, 8);
+}
+
+TEST_F(ParquetScanTest, SparseRowIdsAppendNestedColumnsAcrossRanges) {
+    write_int_list_parquet_file(_file_path);
+    auto reader = create_reader();
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    ASSERT_TRUE(reader->init(&state).ok());
+    reader->set_batch_size(3);
+    std::vector<format::ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    auto request = std::make_shared<format::FileScanRequest>();
+    request->non_predicate_columns = {field_projection(0), 
field_projection(1)};
+    request->row_ids = {0, 2};
+    ASSERT_TRUE(reader->open(request).ok());
+    Block block = build_file_block(schema);
+    size_t rows = 0;
+    bool eof = false;
+    ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+    ASSERT_EQ(rows, 2);
+    
EXPECT_EQ(int32_data_column(*block.get_by_position(0).column).get_element(0), 
1);
+    
EXPECT_EQ(int32_data_column(*block.get_by_position(0).column).get_element(1), 
3);
+    const IColumn* list_column = block.get_by_position(1).column.get();
+    if (const auto* nullable = 
check_and_get_column<ColumnNullable>(*list_column)) {
+        list_column = &nullable->get_nested_column();
+    }
+    const auto& lists = assert_cast<const ColumnArray&>(*list_column);
+    ASSERT_EQ(lists.get_offsets().size(), 2);
+    EXPECT_EQ(lists.get_offsets()[0], 2);
+    EXPECT_EQ(lists.get_offsets()[1], 2);
+    const auto& elements = int32_data_column(lists.get_data());
+    ASSERT_EQ(elements.size(), 2);
+    EXPECT_EQ(elements.get_element(0), 1);
+    EXPECT_EQ(elements.get_element(1), 2);
+}
+
+class CountingParquetRemoteReader final : public io::FileReader {
+public:
+    explicit CountingParquetRemoteReader(io::FileReaderSPtr reader) : 
_reader(std::move(reader)) {}
+    Status close() override { return _reader->close(); }
+    const io::Path& path() const override { return _reader->path(); }
+    size_t size() const override { return _reader->size(); }
+    bool closed() const override { return _reader->closed(); }
+    int64_t mtime() const override { return _reader->mtime(); }
+    std::atomic<size_t> remote_bytes {0};
+    std::atomic<size_t> dryrun_bytes {0};
+
+protected:
+    Status read_at_impl(size_t offset, Slice result, size_t* bytes_read,
+                        const io::IOContext* io_ctx) override {
+        RETURN_IF_ERROR(_reader->read_at(offset, result, bytes_read, io_ctx));
+        remote_bytes += *bytes_read;
+        if (io_ctx != nullptr && io_ctx->is_dryrun) {
+            dryrun_bytes += *bytes_read;
+        }
+        return Status::OK();
+    }
+
+private:
+    io::FileReaderSPtr _reader;
+};
+
+TEST_F(ParquetScanTest, SparseRowIdsBoundWideProjectionReadAhead) {
+    using namespace format::parquet;
+    // Twenty leaves exceed the default aggregate budget if each retains 8 MiB 
of read-ahead.
+    constexpr int leaf_count = 20;
+    std::vector<int32_t> ids(3 * 1024 * 1024);
+    std::iota(ids.begin(), ids.end(), 0);
+    auto values = build_int32_array(ids);
+    std::vector<std::shared_ptr<arrow::Field>> fields;
+    std::vector<std::shared_ptr<arrow::Array>> arrays;
+    for (int leaf = 0; leaf < leaf_count; ++leaf) {
+        fields.push_back(arrow::field("c" + std::to_string(leaf), 
arrow::int32(), false));
+        arrays.push_back(values);
+    }
+    auto table = arrow::Table::Make(arrow::schema(fields), arrays);
+    auto output = arrow::io::FileOutputStream::Open(_file_path).ValueOrDie();
+    ::parquet::WriterProperties::Builder writer_properties;
+    writer_properties.disable_dictionary();
+    writer_properties.compression(::parquet::Compression::UNCOMPRESSED);
+    writer_properties.data_page_version(::parquet::ParquetDataPageVersion::V2);
+    // Arrow otherwise caps each row group at 1M rows, making every chunk 
smaller than 8 MiB.
+    writer_properties.max_row_group_length(ids.size());
+    PARQUET_THROW_NOT_OK(::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), output,
+                                                      ids.size(), 
writer_properties.build()));
+    ASSERT_TRUE(output->Close().ok());
+    io::FileReaderSPtr local;
+    ASSERT_TRUE(io::global_local_filesystem()->open_file(_file_path, 
&local).ok());
+    auto remote = std::make_shared<CountingParquetRemoteReader>(local);
+    io::IOContext io_ctx;
+    io::FileDescription description;
+    description.path = _file_path;
+    description.file_size = local->size();
+    ParquetFileContext context;
+    ASSERT_TRUE(context.open(remote, &io_ctx, false, description).ok());
+    std::vector<std::unique_ptr<ParquetColumnSchema>> schema;
+    ASSERT_TRUE(build_parquet_column_schema(context.native_metadata->schema(), 
&schema).ok());
+    auto request = std::make_shared<format::FileScanRequest>();
+    Block block;
+    for (int leaf = 0; leaf < leaf_count; ++leaf) {
+        request->non_predicate_columns.push_back(field_projection(leaf));
+        request->local_positions.emplace(format::LocalColumnId(leaf), 
format::LocalIndex(leaf));
+        block.insert({schema[leaf]->type->create_column(), schema[leaf]->type, 
schema[leaf]->name});
+    }
+    request->row_ids = {0};
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto plan = std::make_shared<RowGroupScanPlan>();
+    ASSERT_TRUE(plan_parquet_row_groups(*context.native_metadata, schema, 
*request, {}, false,
+                                        plan.get(), &state.timezone_obj(), 
&state, &context)
+                        .ok());
+    ParquetScanScheduler scheduler;
+    scheduler.set_plan(plan);
+    scheduler.set_batch_size(1);
+    scheduler.set_scan_request(request);
+    scheduler.set_runtime_state(&state);
+    scheduler.set_timezone(&state.timezone_obj());
+    size_t rows = 0;
+    bool eof = false;
+    ASSERT_TRUE(scheduler.read_next_batch(context, schema, &block, &rows, 
&eof).ok());
+    ASSERT_EQ(rows, 1);
+    for (int leaf = 0; leaf < leaf_count; ++leaf) {
+        
EXPECT_EQ(int32_data_column(*block.get_by_position(leaf).column).get_element(0),
 0);
+    }
+    size_t retained_buffer_bytes = 0;
+    ASSERT_EQ(scheduler._current_non_predicate_columns.size(), leaf_count);
+    for (const auto& [id, column_reader] : 
scheduler._current_non_predicate_columns) {
+        const auto* adapter = dynamic_cast<const 
NativeColumnReader*>(column_reader.get());
+        ASSERT_NE(adapter, nullptr);
+        const auto* scalar = dynamic_cast<const 
native::ScalarColumnReader<false, false>*>(
+                adapter->_native_reader.get());
+        ASSERT_NE(scalar, nullptr);
+        retained_buffer_bytes += scalar->_stream_reader->_buf_size;
+    }
+    RecordProperty("retained_buffer_bytes", 
std::to_string(retained_buffer_bytes));
+    RecordProperty("remote_bytes", 
std::to_string(remote->remote_bytes.load()));
+    EXPECT_LT(retained_buffer_bytes, 
size_t(config::parquet_rowgroup_max_buffer_mb) << 20);
+    EXPECT_LT(remote->remote_bytes.load(), 
size_t(config::parquet_rowgroup_max_buffer_mb) << 20);
+    EXPECT_EQ(remote->dryrun_bytes.load(), 0);
+}
+
+TEST_F(ParquetScanTest, SparseRowIdsAvoidCachedRemoteChunkPrefetch) {
+    using namespace format::parquet;
+    // Use multiple cache blocks so an eager chunk read cannot hide inside one 
demand read.
+    std::vector<int32_t> ids(1024 * 1024);
+    std::iota(ids.begin(), ids.end(), 0);
+    auto table = arrow::Table::Make(arrow::schema({arrow::field("id", 
arrow::int32(), false)}),
+                                    {build_int32_array(ids)});
+    write_table(_file_path, table, ids.size());
+
+    auto* env = ExecEnv::GetInstance();
+    auto* old_factory = env->file_cache_factory();
+    auto factory = std::make_unique<io::FileCacheFactory>();
+    const auto cache_path = (_test_dir / "cache").string();
+    io::FileCacheSettings settings;
+    settings.storage = "disk";
+    settings.capacity = 16 * 1024 * 1024;
+    settings.query_queue_size = settings.capacity;
+    settings.query_queue_elements = 1024;
+    settings.max_file_block_size = 64 * 1024;
+    const auto old_ttl_gc_interval = 
config::file_cache_background_ttl_gc_interval_ms;
+    const auto old_ttl_info_interval = 
config::file_cache_background_ttl_info_update_interval_ms;
+    // The TTL workers sleep between iterations, so bound the fixture's 
shutdown latency.
+    config::file_cache_background_ttl_gc_interval_ms = 100;
+    config::file_cache_background_ttl_info_update_interval_ms = 100;
+    const auto old_block_size = config::file_cache_each_block_size;
+    config::file_cache_each_block_size = settings.max_file_block_size;
+    auto old_fd_cache = std::move(env->_file_cache_open_fd_cache);
+    auto old_pool = std::move(env->_segment_prefetch_thread_pool);
+    Defer restore([&] {
+        env->_segment_prefetch_thread_pool.reset();
+        factory.reset();
+        env->set_file_cache_factory(old_factory);
+        env->_file_cache_open_fd_cache = std::move(old_fd_cache);
+        env->_segment_prefetch_thread_pool = std::move(old_pool);
+        config::file_cache_each_block_size = old_block_size;
+        config::file_cache_background_ttl_gc_interval_ms = old_ttl_gc_interval;
+        config::file_cache_background_ttl_info_update_interval_ms = 
old_ttl_info_interval;
+    });
+    env->set_file_cache_factory(factory.get());
+    env->_file_cache_open_fd_cache = std::make_unique<io::FDCache>();
+    ASSERT_TRUE(factory->create_file_cache(cache_path, settings).ok());
+    auto* cache = factory->get_by_path(cache_path);
+    ASSERT_NE(cache, nullptr);
+    for (int attempt = 0; attempt < 200 && !cache->get_async_open_success(); 
++attempt) {
+        std::this_thread::sleep_for(std::chrono::milliseconds(5));
+    }
+    ASSERT_TRUE(cache->get_async_open_success());
+    ASSERT_TRUE(ThreadPoolBuilder("parquet_test_prefetch")
+                        .set_min_threads(1)
+                        .set_max_threads(1)
+                        
.build(&ExecEnv::GetInstance()->_segment_prefetch_thread_pool)
+                        .ok());
+    io::FileReaderSPtr local;
+    ASSERT_TRUE(io::global_local_filesystem()->open_file(_file_path, 
&local).ok());
+    auto remote = std::make_shared<CountingParquetRemoteReader>(local);
+    io::FileReaderOptions options;
+    options.cache_type = io::FileCachePolicy::FILE_BLOCK_CACHE;
+    options.cache_base_path = cache_path;
+    options.cache_write_mode = io::CacheWriteMode::SYNC_WRITE;
+    auto cached = std::make_shared<io::CachedRemoteFileReader>(remote, 
options);
+    io::FileCacheStatistics cache_stats;
+    io::IOContext io_ctx;
+    io_ctx.file_cache_stats = &cache_stats;
+    io::FileDescription description;
+    description.path = _file_path;
+    description.file_size = local->size();
+    ParquetFileContext context;
+    ASSERT_TRUE(context.open(cached, &io_ctx, false, description).ok());
+    std::vector<std::unique_ptr<ParquetColumnSchema>> schema;
+    ASSERT_TRUE(build_parquet_column_schema(context.native_metadata->schema(), 
&schema).ok());
+    auto request = std::make_shared<format::FileScanRequest>();
+    request->non_predicate_columns = {field_projection(0)};
+    request->local_positions.emplace(format::LocalColumnId(0), 
format::LocalIndex(0));
+    request->row_ids = {0};
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    RowGroupScanPlan plan;
+    ASSERT_TRUE(plan_parquet_row_groups(*context.native_metadata, schema, 
*request, {}, false,
+                                        &plan, &state.timezone_obj(), &state, 
&context)
+                        .ok());
+    ParquetScanScheduler scheduler;
+    scheduler.set_plan(std::make_shared<RowGroupScanPlan>(std::move(plan)));
+    scheduler.set_scan_request(request);
+    scheduler.set_runtime_state(&state);
+    scheduler.set_timezone(&state.timezone_obj());
+    Block block;
+    block.insert({schema[0]->type->create_column(), schema[0]->type, "id"});
+    size_t rows = 0;
+    bool eof = false;
+    ASSERT_TRUE(scheduler.read_next_batch(context, schema, &block, &rows, 
&eof).ok());
+    ExecEnv::GetInstance()->segment_prefetch_thread_pool()->wait();
+    ASSERT_EQ(rows, 1);
+    
EXPECT_EQ(int32_data_column(*block.get_by_position(0).column).get_element(0), 
0);
+    EXPECT_FALSE(scheduler._current_merge_range_active);
+    EXPECT_FALSE(scheduler._current_non_predicate_prefetched);
+    EXPECT_LT(remote->remote_bytes.load(), local->size() / 2);
+    EXPECT_EQ(remote->dryrun_bytes.load(), 0);
+    // Source counters measure copied bytes; downloads can include cache-block 
alignment padding.
+    EXPECT_GT(cache_stats.bytes_read_from_remote, 0);
+    EXPECT_LE(cache_stats.bytes_read_from_remote, remote->remote_bytes.load());
+}
+
 TEST_F(ParquetScanTest, PredicateOnlyGlobalRowIdKeepsSignedFileLocalId) {
     write_int_pair_parquet_file(_file_path, 6, false);
     format::GlobalRowIdContext context {.version = 7, .backend_id = 123456789, 
.file_id = 42};
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java
index f57d84ca273..e95a9388483 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/FileQueryScanNode.java
@@ -276,12 +276,16 @@ public abstract class FileQueryScanNode extends 
FileScanNode {
         setColumnPositionMapping();
     }
 
+    protected TColumnCategory classifyColumn(SlotDescriptor slot, List<String> 
partitionKeys) {
+        return classifyColumn(slot.getColumn().getName(), partitionKeys);
+    }
+
     /**
-     * Classify a column's category for the BE reader.
+     * Classify projected and lazy columns with the same connector-specific 
rules.
      * Subclasses override this for format-specific classification.
      */
-    protected TColumnCategory classifyColumn(SlotDescriptor slot, List<String> 
partitionKeys) {
-        if (partitionKeys.contains(slot.getColumn().getName())) {
+    protected TColumnCategory classifyColumn(String columnName, List<String> 
partitionKeys) {
+        if (partitionKeys.contains(columnName)) {
             return TColumnCategory.PARTITION_KEY;
         }
         return TColumnCategory.REGULAR;
@@ -339,6 +343,25 @@ public abstract class FileQueryScanNode extends 
FileScanNode {
             columnNameMap.putIfAbsent(columnNames.get(i), i);
         }
 
+        boolean needsRowIdFetch = desc.getSlots().stream()
+                .anyMatch(slot -> 
slot.getColumn().getName().startsWith(Column.GLOBAL_ROWID_COL));
+        if (needsRowIdFetch) {
+            // Lazy slots are absent from the scan tuple. Use the relation's 
full schema so
+            // metadata categories survive pruning without changing physical 
file positions.
+            List<Column> columns = desc.getTable() instanceof ExternalTable
+                    ? ((ExternalTable) 
desc.getTable()).getFullSchema(getRelationSnapshot())
+                    : desc.getTable().getFullSchema();
+            List<String> partitionKeys = getPathPartitionKeys();
+            Map<String, TColumnCategory> columnCategories = new HashMap<>();
+            for (Column column : columns) {
+                TColumnCategory category = classifyColumn(column.getName(), 
partitionKeys);
+                if (category != TColumnCategory.REGULAR) {
+                    columnCategories.put(column.getName(), category);
+                }
+            }
+            params.setColumnNameToCategory(columnCategories);
+        }
+
         for (TFileScanSlotInfo slot : params.getRequiredSlots()) {
             if (!slot.isIsFileSlot()) {
                 continue;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
index 2a45a1b749f..ebae13b7d4c 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/source/IcebergScanNode.java
@@ -2570,17 +2570,17 @@ public class IcebergScanNode extends FileQueryScanNode {
     }
 
     @Override
-    protected TColumnCategory classifyColumn(SlotDescriptor slot, List<String> 
partitionKeys) {
-        if 
(Column.ICEBERG_ROWID_COL.equalsIgnoreCase(slot.getColumn().getName())) {
+    protected TColumnCategory classifyColumn(String columnName, List<String> 
partitionKeys) {
+        if (Column.ICEBERG_ROWID_COL.equalsIgnoreCase(columnName)) {
             return TColumnCategory.SYNTHESIZED;
         }
-        if (slot.getColumn().getName().startsWith(Column.GLOBAL_ROWID_COL)) {
+        if (columnName.startsWith(Column.GLOBAL_ROWID_COL)) {
             return TColumnCategory.SYNTHESIZED;
         }
-        if (IcebergUtils.isIcebergRowLineageColumn(slot.getColumn())) {
+        if (IcebergUtils.isIcebergRowLineageColumn(columnName)) {
             return TColumnCategory.GENERATED;
         }
-        return super.classifyColumn(slot, partitionKeys);
+        return super.classifyColumn(columnName, partitionKeys);
     }
 
     private List<Split> doGetSplits(int numBackends) throws UserException {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java
index a29471aff20..bb3a25c5c97 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/FileQueryScanNodeTest.java
@@ -253,6 +253,46 @@ public class FileQueryScanNodeTest {
         Assert.assertEquals(64 * MB, sv.getFileSplitSizeOnBe());
     }
 
+    @Test
+    public void testRowIdFetchRetainsCategoriesOfPrunedColumns() throws 
Exception {
+        TestFileQueryScanNode node = new TestFileQueryScanNode(new 
SessionVariable()) {
+            @Override
+            protected TColumnCategory classifyColumn(String name, List<String> 
partitionKeys) {
+                if (name.equals("metadata_path") || 
name.equals("metadata_position")) {
+                    return TColumnCategory.SYNTHESIZED;
+                }
+                if (name.equals("generated_col")) {
+                    return TColumnCategory.GENERATED;
+                }
+                return super.classifyColumn(name, partitionKeys);
+            }
+        };
+        node.setTargetTable(table);
+        TupleDescriptor desc = node.getTupleDescriptor();
+        desc.setTable(table);
+        SlotDescriptor sortSlot = new SlotDescriptor(new SlotId(1), desc);
+        sortSlot.setColumn(new Column("id", Type.INT));
+        desc.addSlot(sortSlot);
+        SlotDescriptor rowIdSlot = new SlotDescriptor(new SlotId(2), desc);
+        rowIdSlot.setColumn(new Column(Column.GLOBAL_ROWID_COL, Type.STRING));
+        desc.addSlot(rowIdSlot);
+        List<Column> fullSchema = Arrays.asList(sortSlot.getColumn(), new 
Column("metadata_path", Type.STRING),
+                new Column("metadata_position", Type.BIGINT), new 
Column("generated_col", Type.BIGINT));
+        Mockito.when(table.getBaseSchema(false)).thenReturn(fullSchema);
+        Mockito.when(table.getFullSchema()).thenReturn(fullSchema);
+
+        node.params = new TFileScanRangeParams();
+        UPDATE_REQUIRED_SLOTS_METHOD.invoke(node);
+
+        TFileScanRangeParams params = node.getFileScanRangeParams();
+        Assert.assertEquals(2, params.getRequiredSlotsSize());
+        Assert.assertEquals(Arrays.asList(0), params.getColumnIdxs());
+        Assert.assertEquals(TColumnCategory.SYNTHESIZED, 
params.getColumnNameToCategory().get("metadata_path"));
+        Assert.assertEquals(TColumnCategory.SYNTHESIZED, 
params.getColumnNameToCategory().get("metadata_position"));
+        Assert.assertEquals(TColumnCategory.GENERATED, 
params.getColumnNameToCategory().get("generated_col"));
+        Assert.assertFalse(params.getColumnNameToCategory().containsKey("id"));
+    }
+
     @Test
     public void testUpdateRequiredSlotsPreservesInlineDefaultValueExpr() 
throws Exception {
         SessionVariable sv = new SessionVariable();
diff --git a/gensrc/thrift/PlanNodes.thrift b/gensrc/thrift/PlanNodes.thrift
index 35a04f3f99f..45b65a0d63d 100644
--- a/gensrc/thrift/PlanNodes.thrift
+++ b/gensrc/thrift/PlanNodes.thrift
@@ -693,6 +693,9 @@ struct TFileScanRangeParams {
     35: optional string serialized_table_cache_key
     // 31-33 and 36 are used in master; do not allocate them in branch-4.1.
     37: optional TLanceScanParams lance_scan_params
+    // Non-regular columns in the pinned full schema, including columns pruned 
from phase one.
+    // When present, omitted names are REGULAR. Used to rebuild row-id fetch 
projections.
+    38: optional map<string, TColumnCategory> column_name_to_category
 }
 
 struct TFileRangeDesc {
diff --git 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy
index 28e28bdd887..a4579535a9e 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_v3_row_lineage_query_insert.groovy
@@ -118,6 +118,29 @@ suite("test_iceberg_v3_row_lineage_query_insert", 
"p0,external,iceberg,external_
                     "_last_updated_sequence_number should be non-null for 
${tableName}, row=${rowLineageRows[i]}")
         }
 
+        def originalThreshold = sql("select 
@@topn_lazy_materialization_threshold")[0][0]
+        def originalScannerV2 = sql("select @@enable_file_scanner_v2")[0][0]
+        try {
+            sql "set enable_file_scanner_v2 = true"
+            // Phase two must retain generated-column categories and 
file-level lineage metadata.
+            for (String projection : ["_row_id", 
"_last_updated_sequence_number",
+                                      "id, _row_id, 
_last_updated_sequence_number"]) {
+                String query = "select ${projection} from ${tableName} order 
by id limit 2"
+                sql "set topn_lazy_materialization_threshold = -1"
+                def eagerRows = sql query
+                sql "set topn_lazy_materialization_threshold = 10"
+                explain {
+                    sql query
+                    contains "VMaterializeNode"
+                }
+                assertEquals(eagerRows, sql(query))
+            }
+        } finally {
+            // UNSET requires the experimental prefix in 4.1; preserve the 
caller's values even on failure.
+            sql "set topn_lazy_materialization_threshold = 
${originalThreshold}"
+            sql "set enable_file_scanner_v2 = ${originalScannerV2}"
+        }
+
         long firstRowId = rowLineageRows[0][1].toString().toLong()
         long secondRowId = rowLineageRows[1][1].toString().toLong()
         assertTrue(firstRowId < secondRowId,


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

Reply via email to