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

Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


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

commit bda646ee62d71c50831e4ca3f538ebb3631e7660
Author: Gabriel <[email protected]>
AuthorDate: Mon Sep 14 09:51:57 2026 +0800

    [Enhancement](file scanner) Support row-id fetch in FileScannerV2 (#67906)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    
    FileScannerV2 did not support selective reads by absolute file row
    position. As a result, TopN two-phase materialization still had to use
    the legacy FileScanner for its second-phase Parquet/ORC fetch.
    
    This change carries the selected row positions through TableReader and
    FileScanRequest, builds selective Parquet row ranges, seeks requested
    ORC rows, and routes supported second-phase fetches through
    FileScannerV2. Table-format variants that V2 does not support retain the
    existing V1 fallback. Phase two reuses the phase-one scanner selection
    policy, including the option presence bit, so disabling or omitting
    `enable_file_scanner_v2` retains V1. Explicit row-ID requests bypass
    whole-chunk Parquet cache prefetch and range merging. Rebuilt fetch
    projections preserve Thrift slot presence bits and the pinned full
    schema's non-regular column categories, including slots pruned from
    phase one. Hive partition columns and synthesized metadata columns do
    not consume physical column indexes. Fetches retain original Iceberg
    file paths and row lineage while removing delete files, without mutating
    shared file mappings.
    
    ### Release note
    
    Support row-id fetch for Parquet and ORC in FileScannerV2.
    
    ### Check List (For Author)
    
    - Test
        - [x] Unit Test
    - `ParquetScanTest.ReadsOnlyRequestedAbsoluteFileRowsAcrossRowGroups`
            - `NewOrcReaderTest.ReadsOnlyRequestedAbsoluteFileRows`
    - `RowIdStorageReaderTest.ExternalScannerSelectionRespectsRolloutOption`
    -
    `RowIdStorageReaderTest.ExternalScannerSelectionKeepsUnsupportedFormatsOnV1`
            - `ParquetScanTest.SparseRowIdsAvoidCachedRemoteChunkPrefetch`
    -
    
`RowIdStorageReaderTest.ExternalFetchPartitionSlotsPreserveHivePositionMapping`
    -
    `RowIdStorageReaderTest.ExternalFetchPreservesPrunedMetadataCategories`
    - `RowIdStorageReaderTest.ExternalFetchPreservesIcebergFileMetadata`
    - `FileQueryScanNodeTest.testRowIdFetchRetainsCategoriesOfPrunedColumns`
    - Added lazy metadata comparisons to
    `test_iceberg_file_metadata_columns` for Parquet and ORC.
    - Validation: 283 focused BE unit tests passed under ASAN using
    `run-be-ut.sh`; 16 targeted FE tests passed after temporarily excluding
    an unrelated pre-existing IVM test compilation error. Clang-format 16,
    FE Checkstyle, and build hygiene passed. Clang-tidy was blocked by
    pre-existing unmatched `NOLINTEND` diagnostics. Full SQL integration
    tests were not run locally.
    - Behavior changed:
    - [x] Yes. TopN two-phase materialization uses FileScannerV2 for
    supported Parquet and ORC scans.
    - Does this need documentation?
        - [x] No.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 be/src/exec/operator/file_scan_operator.cpp        |  10 +-
 be/src/exec/operator/file_scan_operator.h          |   4 +-
 be/src/exec/rowid_fetcher.cpp                      | 135 +++++++++-----
 be/src/exec/rowid_fetcher.h                        |  11 ++
 be/src/exec/scan/file_scanner_v2.cpp               | 124 +++++++++++--
 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          |  48 +++--
 be/src/format_v2/parquet/parquet_scan.h            |   1 +
 be/src/format_v2/table_reader.cpp                  |   9 +-
 be/src/format_v2/table_reader.h                    |   7 +-
 be/test/exec/rowid_fetcher_test.cpp                | 202 ++++++++++++++++++++-
 be/test/format_v2/orc/orc_reader_test.cpp          |  32 ++++
 be/test/format_v2/parquet/parquet_scan_test.cpp    | 171 +++++++++++++++++
 .../doris/datasource/scan/FileQueryScanNode.java   |  28 ++-
 .../datasource/scan/PluginDrivenScanNode.java      |   5 +-
 .../datasource/scan/FileQueryScanNodeTest.java     |  40 ++++
 gensrc/thrift/PlanNodes.thrift                     |   3 +
 .../test_iceberg_file_metadata_columns.groovy      |  16 ++
 23 files changed, 839 insertions(+), 94 deletions(-)

diff --git a/be/src/exec/operator/file_scan_operator.cpp 
b/be/src/exec/operator/file_scan_operator.cpp
index 5290e3078fb..6e2fb81602e 100644
--- a/be/src/exec/operator/file_scan_operator.cpp
+++ b/be/src/exec/operator/file_scan_operator.cpp
@@ -135,13 +135,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) {
     // ADBC only has a FileScannerV2 reader, and enable_file_scanner_v2 is a 
session variable marked
     // fuzzy=true, so the regression harness flips it to false at random. 
Without letting adbc
     // through unconditionally, those queries land in v1, which has no "adbc" 
branch, and come back
@@ -190,7 +190,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 6128715ddb7..45901d82858 100644
--- a/be/src/exec/operator/file_scan_operator.h
+++ b/be/src/exec/operator/file_scan_operator.h
@@ -54,6 +54,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);
@@ -69,8 +71,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);
 
     PushDownType _should_push_down_is_null_predicate(VectorizedFnCall* 
fn_call) const override {
         return fn_call->fn().name.function_name == "is_null_pred" ||
diff --git a/be/src/exec/rowid_fetcher.cpp b/be/src/exec/rowid_fetcher.cpp
index 05a62e70e13..fca26050618 100644
--- a/be/src/exec/rowid_fetcher.cpp
+++ b/be/src/exec/rowid_fetcher.cpp
@@ -42,7 +42,9 @@
 #include "core/column/column.h"
 #include "core/data_type/data_type_struct.h"
 #include "core/data_type_serde/data_type_serde.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 "io/io_common.h"
@@ -437,6 +439,28 @@ 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;
+}
+
 Status RowIdStorageReader::read_external_row_from_file_mapping(
         size_t idx, const std::multimap<segment_v2::rowid_t, size_t>& row_ids,
         const std::shared_ptr<FileMapping>& file_mapping,
@@ -467,25 +491,30 @@ Status 
RowIdStorageReader::read_external_row_from_file_mapping(
     scan_blocks[idx] = Block(scan_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");
     {
-        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, 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, 
read_ids, &scan_blocks[idx],
+                                                       
&fetch_statistics[idx].init_reader_ms,
+                                                       
&fetch_statistics[idx].get_block_ms));
+        } else {
+            // Phase one can still use V1 for table-format variants 
unsupported by V2, so keep the
+            // matching phase-two path instead of turning a previously valid 
query into an error.
+            auto 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, read_ids, &scan_blocks[idx], 
external_info,
+                    &fetch_statistics[idx].init_reader_ms, 
&fetch_statistics[idx].get_block_ms));
+        }
     }
 
     if (scan_blocks[idx].rows() != read_ids.size()) {
@@ -506,7 +535,7 @@ Status 
RowIdStorageReader::read_external_row_from_file_mapping(
     }
 
     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(
@@ -514,7 +543,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());
@@ -609,6 +638,47 @@ Status RowIdStorageReader::submit_external_scan_tasks(
     return scan_status.ok() ? Status::OK() : scan_status.status();
 }
 
+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_batch_external_row(
         const uint64_t workload_group_id, const PRequestBlockDesc& 
request_block_desc,
         std::shared_ptr<IdFileMap> id_file_map, std::vector<SlotDescriptor>& 
slots,
@@ -641,15 +711,6 @@ Status RowIdStorageReader::read_batch_external_row(
 
         DCHECK(id_file_map->get_external_scan_params().contains(plan_node_id));
         const auto* old_scan_params = 
&(id_file_map->get_external_scan_params().at(plan_node_id));
-        rpc_scan_params = *old_scan_params;
-
-        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());
-
         std::unordered_map<std::string, size_t> source_column_to_scan_idx;
 
         result_column_to_scan_column.reserve(slots.size());
@@ -668,24 +729,12 @@ Status RowIdStorageReader::read_batch_external_row(
             }
         }
 
-        for (auto slot_idx = 0; slot_idx < scan_slots.size(); ++slot_idx) {
-            auto& slot = scan_slots[slot_idx];
+        for (auto& slot : scan_slots) {
             tuple_desc.add_slot(&slot);
             colname_to_slot_id[slot.col_name()] = slot.id();
-            TFileScanSlotInfo slot_info;
-            slot_info.slot_id = slot.id();
-            auto column_idx = scan_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);
         }
+        rpc_scan_params = build_external_scan_params(*old_scan_params, 
first_scan_range_desc,
+                                                     scan_slots, 
scan_column_idxs);
 
         const auto& query_options = id_file_map->get_query_options();
         const auto& query_globals = id_file_map->get_query_globals();
@@ -866,9 +915,9 @@ Status RowIdStorageReader::read_batch_external_row(
                                          std::to_string(*init_reader_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));
     }
 
diff --git a/be/src/exec/rowid_fetcher.h b/be/src/exec/rowid_fetcher.h
index 7c2dd3d1378..9b98d7b9ab1 100644
--- a/be/src/exec/rowid_fetcher.h
+++ b/be/src/exec/rowid_fetcher.h
@@ -35,6 +35,7 @@
 namespace doris {
 
 class RuntimeState;
+class TQueryOptions;
 class TupleDescriptor;
 class ScannerScheduler;
 namespace io {
@@ -81,6 +82,9 @@ public:
     static Status read_by_rowids(const PMultiGetRequestV2& request, 
PMultiGetResponseV2* response);
 
 private:
+    static bool should_use_file_scanner_v2(const TQueryOptions& query_options,
+                                           const TFileScanRangeParams& 
scan_params,
+                                           const TFileRangeDesc& range);
     struct ExternalFetchStatistics;
 
     static Status read_doris_format_row(
@@ -118,6 +122,13 @@ private:
             const std::unordered_map<std::string, int>& colname_to_slot_id,
             std::counting_semaphore<>& semaphore, TupleDescriptor& tuple_desc);
 
+    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);
+
     static std::string source_column_key(const SlotDescriptor& slot, uint32_t 
column_idx);
 
     friend class RowIdStorageReaderTest;
diff --git a/be/src/exec/scan/file_scanner_v2.cpp 
b/be/src/exec/scan/file_scanner_v2.cpp
index 96387b92f66..c64d64fbd1f 100644
--- a/be/src/exec/scan/file_scanner_v2.cpp
+++ b/be/src/exec/scan/file_scanner_v2.cpp
@@ -75,9 +75,13 @@
 #include "runtime/runtime_state.h"
 #include "service/backend_options.h"
 #include "storage/id_manager.h"
+#include "util/stopwatch.hpp"
 #include "util/string_util.h"
 
 namespace doris {
+const std::string FileScannerV2::FileReadBytesProfile = "FileReadBytes";
+const std::string FileScannerV2::FileReadTimeProfile = "FileReadTime";
+
 namespace {
 
 constexpr int kIcebergPositionDeleteContent = 1;
@@ -397,12 +401,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(
@@ -593,18 +597,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({
@@ -614,15 +621,97 @@ 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,
+                .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);
+    return Status::OK();
+}
+
 Status FileScannerV2::_create_table_reader_for_format(
         const TFileRangeDesc& range, std::unique_ptr<format::TableReader>* 
reader) const {
     DORIS_CHECK(reader != nullptr);
@@ -693,6 +782,7 @@ Status FileScannerV2::_prepare_table_reader_split(const 
TFileRangeDesc& range,
             .current_range = range,
             .current_split_format = current_split_format,
             .global_rowid_context = _create_global_rowid_context(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 ed1dce98821..13a6d593a3c 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;
@@ -108,6 +111,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 8a9df5d7953..2d94a23d673 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()
+    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()
         << ", metadata_pruning_safe_conjunct_count=" << 
metadata_pruning_safe_conjunct_count
         << ", constant_pruning_safe_table_filter_count=" << 
constant_pruning_safe_table_filter_count
         << ", delete_conjunct_count=" << delete_conjuncts.size() << ", 
variant_schema_overrides="
diff --git a/be/src/format_v2/file_reader.h b/be/src/format_v2/file_reader.h
index 91a7873d84d..700ccf9439d 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>
@@ -102,6 +103,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.
@@ -374,8 +381,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 b59eafbadfb..b7ffc1c9134 100644
--- a/be/src/format_v2/orc/orc_reader.cpp
+++ b/be/src/format_v2/orc/orc_reader.cpp
@@ -778,6 +778,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;
 };
@@ -1239,6 +1240,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;
@@ -1296,6 +1298,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();
 }
@@ -1650,7 +1662,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;
@@ -1947,11 +1964,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();
             }
@@ -1959,6 +1988,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());
@@ -1979,6 +2011,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) {
@@ -1989,7 +2025,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 a67dc5ad7f5..a58af5451a7 100644
--- a/be/src/format_v2/orc/orc_reader.h
+++ b/be/src/format_v2/orc/orc_reader.h
@@ -58,6 +58,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 5a1ebd64614..61aad3362bb 100644
--- a/be/src/format_v2/parquet/parquet_reader.h
+++ b/be/src/format_v2/parquet/parquet_reader.h
@@ -68,6 +68,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 b4cd731f12b..dc2741f7d3e 100644
--- a/be/src/format_v2/parquet/parquet_scan.cpp
+++ b/be/src/format_v2/parquet/parquet_scan.cpp
@@ -556,7 +556,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;
         plan->row_groups.push_back(std::move(row_group_plan));
     }
@@ -1214,14 +1235,14 @@ Status ParquetScanScheduler::open_next_row_group(
     RETURN_IF_ERROR(detail::build_native_prefetch_ranges(
             thrift_metadata, file_schema, request_scan_columns(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);
     }
@@ -1263,8 +1284,9 @@ Status ParquetScanScheduler::open_next_row_group(
     // changes row/column materialization order.
     if (!_current_merge_range_active) {
         const auto prefetch_columns = 
adaptive_predicate_prefetch_columns(request);
-        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 : request.non_predicate_columns) {
         const auto local_id = col.column_id();
@@ -1302,7 +1324,7 @@ Status ParquetScanScheduler::open_next_row_group(
         // With no row-level filters there is no lazy-read decision to wait 
for, so start warming
         // 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,
+        RETURN_IF_ERROR(prefetch_current_row_group_columns(file_context, 
file_schema, request,
                                                            
physical_non_predicate_columns(request),
                                                            
&_current_non_predicate_prefetched));
     }
@@ -2519,10 +2541,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;
@@ -2640,7 +2666,7 @@ Status ParquetScanScheduler::read_current_row_group_batch(
         // Do not prefetch lazy output columns until at least one row survives 
filtering. This is
         // the same decision point where the v2 reader switches from 
predicate-only reads to
         // materializing non-predicate columns, so fully filtered batches 
avoid unnecessary IO.
-        RETURN_IF_ERROR(prefetch_current_row_group_columns(file_context, 
file_schema,
+        RETURN_IF_ERROR(prefetch_current_row_group_columns(file_context, 
file_schema, request,
                                                            
physical_non_predicate_columns(request),
                                                            
&_current_non_predicate_prefetched));
     }
diff --git a/be/src/format_v2/parquet/parquet_scan.h 
b/be/src/format_v2/parquet/parquet_scan.h
index 0cccbc31cb2..25367c84595 100644
--- a/be/src/format_v2/parquet/parquet_scan.h
+++ b/be/src/format_v2/parquet/parquet_scan.h
@@ -242,6 +242,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/table_reader.cpp 
b/be/src/format_v2/table_reader.cpp
index 76aec676893..3e654ab594a 100644
--- a/be/src/format_v2/table_reader.cpp
+++ b/be/src/format_v2/table_reader.cpp
@@ -1161,6 +1161,7 @@ Status TableReader::refresh_conjuncts(VExprContextSPtrs 
conjuncts) {
             _file_scan_request == nullptr ? nullptr : 
&_file_scan_request->local_positions,
             _file_scan_request == nullptr ? nullptr
                                           : 
&_file_scan_request->non_predicate_positions));
+    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() &&
@@ -1470,6 +1471,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;
@@ -1494,9 +1496,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 097cee93734..1f890a514fe 100644
--- a/be/src/format_v2/table_reader.h
+++ b/be/src/format_v2/table_reader.h
@@ -186,6 +186,9 @@ struct SplitReadOptions {
     TFileRangeDesc current_range;
     FileFormat current_split_format = FileFormat::PARQUET;
     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.
@@ -456,6 +459,7 @@ protected:
         auto file_request = std::make_shared<FileScanRequest>();
         RETURN_IF_ERROR(_data_reader.column_mapper->create_scan_request(
                 _table_filters, _projected_columns, file_request.get(), 
_runtime_state));
+        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);
@@ -1048,7 +1052,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();
         }
 
@@ -2251,6 +2255,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;
     bool _current_split_pruned = false;
     TableColumnMapperOptions _mapper_options;
diff --git a/be/test/exec/rowid_fetcher_test.cpp 
b/be/test/exec/rowid_fetcher_test.cpp
index b2fcbbac236..8f14aa6654b 100644
--- a/be/test/exec/rowid_fetcher_test.cpp
+++ b/be/test/exec/rowid_fetcher_test.cpp
@@ -22,8 +22,13 @@
 #include <string>
 #include <vector>
 
+#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 {
 
@@ -40,6 +45,8 @@ public:
 protected:
     struct SlotSpec {
         std::string col_name = "c";
+        int32_t slot_id = 0;
+        PrimitiveType type = TYPE_INT;
         int32_t col_unique_id = 1;
         std::vector<std::string> column_paths = {};
         TColumnAccessPaths access_paths = {};
@@ -47,11 +54,12 @@ protected:
 
     static SlotDescriptor make_slot(const SlotSpec& spec) {
         TSlotDescriptor tdesc = TSlotDescriptorBuilder()
-                                        .type(TYPE_INT)
+                                        .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);
         tdesc.__set_column_paths(spec.column_paths);
         if (!spec.access_paths.empty()) {
@@ -76,6 +84,198 @@ protected:
     }
 };
 
+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_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;
+    source_params.__set_column_name_to_category(
+            {{"_file", TColumnCategory::SYNTHESIZED},
+             {"_pos", 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 : {"_file", "_pos", "generated_col", 
"partition_col", "value"}) {
+        slots.emplace_back(
+                make_slot({.col_name = name,
+                           .slot_id = static_cast<int32_t>(slots.size()),
+                           .type = std::string_view(name) == "_file" ? 
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};
+    std::vector<format::ColumnDefinition> columns;
+    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);
+        auto column = FileScannerV2::_build_table_column(&slots[i]);
+        column.is_synthesized =
+                info.__isset.category && info.category == 
TColumnCategory::SYNTHESIZED;
+        columns.emplace_back(std::move(column));
+    }
+    format::TableColumnMapper mapper({.mode = 
format::TableColumnMappingMode::BY_NAME,
+                                      .enable_iceberg_metadata_virtual_columns 
= true});
+    ASSERT_TRUE(mapper.create_mapping({columns[0], columns[1]}, {}, {}).ok());
+    EXPECT_EQ(mapper.mappings()[0].virtual_column_type,
+              format::TableVirtualColumnType::ICEBERG_FILE_PATH);
+    EXPECT_EQ(mapper.mappings()[1].virtual_column_type,
+              format::TableVirtualColumnType::ICEBERG_ROW_POSITION);
+
+    // 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());
+        }
+    }
+}
+
 TEST_F(RowIdStorageReaderTest, SameSourceColumnSharesKey) {
     // The bug case: one physical column projected twice must dedup onto one 
scan column.
     const SlotDescriptor first = make_slot({});
diff --git a/be/test/format_v2/orc/orc_reader_test.cpp 
b/be/test/format_v2/orc/orc_reader_test.cpp
index 67b5d2a1676..d9be5a5e6d0 100644
--- a/be/test/format_v2/orc/orc_reader_test.cpp
+++ b/be/test/format_v2/orc/orc_reader_test.cpp
@@ -10212,6 +10212,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 17d80897b7a..6d10bc2d427 100644
--- a/be/test/format_v2/parquet/parquet_scan_test.cpp
+++ b/be/test/format_v2/parquet/parquet_scan_test.cpp
@@ -25,7 +25,9 @@
 #include <parquet/arrow/writer.h>
 #include <parquet/encoding.h>
 
+#include <atomic>
 #include <bit>
+#include <chrono>
 #include <cstdlib>
 #include <cstring>
 #include <filesystem>
@@ -36,6 +38,7 @@
 #include <optional>
 #include <set>
 #include <string>
+#include <thread>
 #include <utility>
 #include <vector>
 
@@ -72,6 +75,11 @@
 #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/runtime_state.h"
 #include "storage/index/zone_map/zonemap_eval_context.h"
@@ -79,6 +87,8 @@
 #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 {
@@ -2513,6 +2523,167 @@ 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}));
+}
+
+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, 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_column_buffer = config::parquet_column_max_buffer_mb;
+    config::parquet_column_max_buffer_mb = 1;
+    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::parquet_column_max_buffer_mb = old_column_buffer;
+        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::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/scan/FileQueryScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java
index 775b811e5ee..63d45e48688 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/FileQueryScanNode.java
@@ -241,12 +241,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;
@@ -300,8 +304,24 @@ public abstract class FileQueryScanNode extends 
FileScanNode {
         // Pre-index columns into a Map for O(1) lookup
         List<Column> columns = getPinnedFullSchema();
         Map<String, Integer> columnNameMap = new HashMap<>(columns.size());
+        boolean needsRowIdFetch = desc.getSlots().stream()
+                .anyMatch(slot -> 
slot.getColumn().getName().startsWith(Column.GLOBAL_ROWID_COL));
+        List<String> partitionKeys = needsRowIdFetch ? getPathPartitionKeys() 
: Collections.emptyList();
+        Map<String, TColumnCategory> columnCategories = new HashMap<>();
         for (int i = 0; i < columns.size(); i++) {
-            columnNameMap.putIfAbsent(columns.get(i).getName(), i);
+            String columnName = columns.get(i).getName();
+            columnNameMap.putIfAbsent(columnName, i);
+            if (needsRowIdFetch) {
+                TColumnCategory category = classifyColumn(columnName, 
partitionKeys);
+                if (category != TColumnCategory.REGULAR) {
+                    columnCategories.put(columnName, category);
+                }
+            }
+        }
+        if (needsRowIdFetch) {
+            // Lazy slots are already absent from the scan tuple. Preserve 
their categories from
+            // the pinned full schema so phase two cannot mistake metadata for 
physical columns.
+            params.setColumnNameToCategory(columnCategories);
         }
 
         for (TFileScanSlotInfo slot : params.getRequiredSlots()) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
index 3229689f017..9af04f0f833 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/datasource/scan/PluginDrivenScanNode.java
@@ -782,8 +782,7 @@ public class PluginDrivenScanNode extends FileQueryScanNode 
{
      * Everything else falls through to {@code super} (partition key / 
regular).
      */
     @Override
-    protected TColumnCategory classifyColumn(SlotDescriptor slot, List<String> 
partitionKeys) {
-        String name = slot.getColumn().getName();
+    protected TColumnCategory classifyColumn(String name, List<String> 
partitionKeys) {
         if (name.startsWith(Column.GLOBAL_ROWID_COL)) {
             return TColumnCategory.SYNTHESIZED;
         }
@@ -794,7 +793,7 @@ public class PluginDrivenScanNode extends FileQueryScanNode 
{
         if (category == ConnectorColumnCategory.GENERATED) {
             return TColumnCategory.GENERATED;
         }
-        return super.classifyColumn(slot, partitionKeys);
+        return super.classifyColumn(name, partitionKeys);
     }
 
     /**
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileQueryScanNodeTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileQueryScanNodeTest.java
index 5f608d73497..1afdc77defe 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileQueryScanNodeTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/datasource/scan/FileQueryScanNodeTest.java
@@ -264,6 +264,46 @@ public class FileQueryScanNodeTest {
         
Assertions.assertFalse(node.getFileScanRangeParams().isSetHiveParquetTimeZone());
     }
 
+    @Test
+    public void testRowIdFetchRetainsCategoriesOfPrunedColumns() throws 
Exception {
+        TestFileQueryScanNode node = new TestFileQueryScanNode(new 
SessionVariable()) {
+            @Override
+            protected TColumnCategory classifyColumn(String name, List<String> 
partitionKeys) {
+                if (name.equals("_file") || name.equals("_pos")) {
+                    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.getId());
+        sortSlot.setColumn(new Column("id", Type.INT));
+        desc.addSlot(sortSlot);
+        SlotDescriptor rowIdSlot = new SlotDescriptor(new SlotId(2), 
desc.getId());
+        rowIdSlot.setColumn(new Column(Column.GLOBAL_ROWID_COL, Type.STRING));
+        desc.addSlot(rowIdSlot);
+        List<Column> fullSchema = Arrays.asList(sortSlot.getColumn(), new 
Column("_file", Type.STRING),
+                new Column("_pos", Type.BIGINT), new Column("generated_col", 
Type.BIGINT));
+        Mockito.when(table.getBaseSchema(false)).thenReturn(fullSchema);
+        Mockito.when(table.getFullSchema()).thenReturn(fullSchema);
+
+        node.initSchemaParamsForTest();
+        UPDATE_REQUIRED_SLOTS_METHOD.invoke(node);
+
+        TFileScanRangeParams params = node.getFileScanRangeParams();
+        Assertions.assertEquals(2, params.getRequiredSlotsSize());
+        Assertions.assertEquals(Arrays.asList(0), params.getColumnIdxs());
+        Assertions.assertEquals(TColumnCategory.SYNTHESIZED, 
params.getColumnNameToCategory().get("_file"));
+        Assertions.assertEquals(TColumnCategory.SYNTHESIZED, 
params.getColumnNameToCategory().get("_pos"));
+        Assertions.assertEquals(TColumnCategory.GENERATED, 
params.getColumnNameToCategory().get("generated_col"));
+        
Assertions.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 1c30a652fe9..8e443ab0f03 100644
--- a/gensrc/thrift/PlanNodes.thrift
+++ b/gensrc/thrift/PlanNodes.thrift
@@ -597,6 +597,9 @@ struct TFileScanRangeParams {
     // values unchanged. When present, only INT96 TIMESTAMP values are 
converted with this zone.
     36: optional string hive_parquet_time_zone
     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_file_metadata_columns.groovy
 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_file_metadata_columns.groovy
index d4dde31313e..6a19f385668 100644
--- 
a/regression-test/suites/external_table_p0/iceberg/test_iceberg_file_metadata_columns.groovy
+++ 
b/regression-test/suites/external_table_p0/iceberg/test_iceberg_file_metadata_columns.groovy
@@ -163,5 +163,21 @@ suite("test_iceberg_file_metadata_columns", 
"p0,external,iceberg,external_docker
         """
 
         verifyFileMetadata(tableName, format)
+
+        // Compare against an eager scan: file paths depend on the writer, and 
metadata slots
+        // pruned from phase one must still be synthesized during the row-id 
fetch.
+        for (String projection : ["`_file`", "`_pos`", "`_file`, `_pos`",
+                                  "id, payload, `_file`, `_pos`"]) {
+            String query = "select ${projection} from ${tableName} order by id 
limit 3"
+            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))
+        }
+
     }
 }


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

Reply via email to