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

yiguolei 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 e493c61d44a [improvement](be) Prioritize Parquet V2 row groups for 
TopN scans (#69646)
e493c61d44a is described below

commit e493c61d44af3de66134c96ef25796492e61c79b
Author: Gabriel <[email protected]>
AuthorDate: Wed Oct 28 10:55:36 2026 +0800

    [improvement](be) Prioritize Parquet V2 row groups for TopN scans (#69646)
    
    ### What problem does this PR solve?
    
    Problem Summary:
    
    Parquet V2 currently visits row groups in physical file order. For a
    small `ORDER BY ... LIMIT`, the best values can be near the end of a
    file. Earlier groups are then decoded before the TopN sink publishes a
    useful bound. Even after a bound arrives, the deferred row-group path
    does not recheck the footer unless a new scan request is published; TopN
    updates its bound in place. `VTopNPred` also lacks the ZoneMap interface
    needed to reject whole row groups.
    
    For example, consider four groups containing consecutive integer keys
    `[300, 399]`, `[200, 299]`, `[100, 199]`, `[0, 99]` and `ORDER BY k ASC
    LIMIT 3`. Reading the last group first produces a bound of `2`; the
    other three groups can be rejected using their footer minimum before
    dictionary/index probes, prefetch, or data decoding. The descending case
    uses the footer maximum.
    
    ### Optimization approach
    
    - Add conservative ZoneMap evaluation to `VTopNPred`. Snapshot the
    current runtime bound at each evaluation, retain equality for secondary
    sort keys, honor NULL ordering, and fall back when types, ranges, or
    floating-point NaN information are unsuitable.
    - Prioritize selected row groups by the leading TopN slot's sanitized
    footer minimum/maximum. Resolve the file-local slot through the
    request's column mapping. Keep execution indices separate from the
    physical plans so row IDs, delete coordinates, and Condition Cache
    boundaries remain valid.
    - Recheck the latest TopN bound at every row-group boundary before
    expensive metadata probes and I/O. Files without statistics retain the
    existing path. Reordering respects row-ID fetch order and the request's
    existing metadata-pruning boundary; missing or unusable statistics never
    exclude a group by themselves.
    
    ### Result and evaluation contract
    
    The optimization must preserve the final TopN result, including
    secondary-key ties and NULL placement. It may avoid evaluating
    expressions or materializing columns for rows that a valid TopN bound
    proves cannot contribute to that result. Preserving potential exceptions
    from such excluded rows is not a requirement: a DATE overflow in a
    discarded row group, for example, does not require reading that group.
    Existing TopN/runtime-filter pushdown eligibility and file-to-table
    localization remain unchanged.
    
    No executed error is caught, ignored, or converted to success. A
    conversion that is actually needed still follows the existing
    strict/non-strict rules and propagates its failure. This distinction
    also applies to unprojected equality-delete keys and set-operation
    inputs reduced through existing runtime filters. It does not permit
    skipping candidate rows, bypassing deletes for retained rows,
    re-evaluating a volatile leading key as a different value, or trusting
    incompatible statistics.
    
    Earlier follow-ups added FE expression whitelists and table-mapping
    restrictions to preserve errors in rows outside the result. Those
    restrictions are removed; they unnecessarily reduced existing
    optimization eligibility. The production change is confined to the TopN
    ZoneMap capability and Parquet scheduling. Existing metadata-pruning
    boundaries and ordered row-ID fetches remain respected. Upgrade
    compatibility for legacy FE requests using Parquet V1 is outside the
    scope of this PR; the follow-up dedicated to that case has been
    reverted.
    
    ### Eligibility and limits
    
    - FE must already generate a TopN runtime filter for the plan and
    leading-key type. Its existing selectivity check remains
    `max(estimated_scan_rows, 1) * topn_filter_ratio > LIMIT + OFFSET`
    (default ratio: `0.5`); existing window, CTE, nested-TopN and join-side
    rules remain unchanged.
    - This row-group scheduling optimization applies to Parquet V2. After
    localization, the leading-key filter must reference a direct,
    non-repeated primitive file column with a compatible type. Arbitrary
    computed keys such as `abs(k)` are not mapped to footer bounds.
    Projected or secondary-key expressions do not need a new non-throwing
    proof.
    - Reordering requires at least two selected row groups, at least one
    usable leading-key range, no ordered row-ID fetch, and all localized
    conjuncts inside the existing metadata-pruning-safe boundary. Ordering
    is within the current file/split, without coordinating a global order
    across files or scanners. Current-bound footer rechecks can still apply
    without reordering when their own eligibility holds.
    - ASC/DESC, NULL ordering, OFFSET and multiple sort keys retain their
    existing semantics. Leading-key equality is retained for secondary keys.
    Missing/untrusted statistics, incompatible types, or an unavailable
    bound retain candidates; they cannot by themselves reject a group.
    - Benefits depend on reaching promising groups before the others are
    read and on having separable ranges. Overlapping ranges, many
    leading-key ties, one group per split, or late feedback can
    substantially reduce the gain. No new production setting is introduced.
    
    ### Measured optimization effect
    
    The included opt-in `ParquetScanTest.TopNRowGroupOrderMicrobench`
    exercises the actual Parquet V2 reader plus a bounded TopK heap and
    per-block updates of the real runtime predicate. Its workload
    corresponds to:
    
    ```sql
    SELECT k FROM parquet_source ORDER BY k ASC LIMIT 10;
    SELECT k FROM parquet_source ORDER BY k DESC LIMIT 10;
    ```
    
    Fixture: one required INT32 column, 128 row groups x 4,096 rows (524,288
    rows), disjoint key ranges, and the best group physically last for each
    direction. Files use uncompressed PLAIN DataPageV2 with dictionary and
    PageIndex disabled, isolating footer pruning. Every iteration verifies
    all ten final values.
    
    | Per reader query | Before | After |
    | --- | ---: | ---: |
    | Row groups decoded | 128 | 1 |
    | RawRowsRead | 524,288 | 4,096 (-99.22%) |
    | RowGroupsFilteredByMinMax | 0 | 127 |
    | Actual local read bytes | 2,149,888 | 65,602 (-96.95%) |
    | ASC median latency | 10.698 ms | 0.669 ms (~16.0x) |
    | DESC median latency | 8.590 ms | 0.660 ms (~13.0x) |
    
    Method: Clang 20 RELEASE builds, warm local filesystem cache, the same
    pinned CPU, nine paired samples alternating baseline/candidate order,
    and 32 reader queries per sample after warmup. These paired timings were
    collected before the eligibility cleanup; the direct one-column workload
    exercises the same scheduling path. Fresh reader correctness tests still
    verify best-first reads and late-bound group rejection. The baseline
    restores `vtopn_pred.{h,cpp}` and `parquet_scan.{h,cpp}` from the
    pre-optimization base `8d2bd1e14b6d95d923d43e00857da326639cc466` while
    retaining the identical benchmark harness and request plumbing; the
    benchmark calls ParquetReader directly. All paired checksums match. Work
    counters are identical across all samples. ASC candidate samples range
    from 0.647 to 1.097 ms and DESC from 0.642 to 0.745 ms; the shared host
    has timing outliers. These are reader microbenchmark results; complete
    SQL gains depend on file splits, parallelism, feedback latency, and
    storage.
    
    ### Validation and reproduction
    
    - Fresh verification of the revised implementation: 497 BE tests passed
    (one opt-in microbenchmark skipped in the ordinary run), and all 31 FE
    tests passed across the four suites below. The three aggregation query
    shapes also pass the final translated `TOPN OPT:` EXPLAIN assertion. The
    output-evolution optimization test and FE filter-generation test first
    failed on the preceding implementation, then passed after removal of the
    unnecessary fences.
    - Reader integration covers ASC/DESC, top-level/nested
    DATE-to-TIMESTAMP_NS output evolution, strict cast on/off, strict
    identity DATE materialization, and an unprojected Iceberg
    equality-delete key. Excluded groups are skipped. The native and
    hidden-delete cases include ordinary-scan and reversed-TopN controls
    that still raise the original conversion error when the invalid group is
    required; existing output-evolution tests also retain their overflow
    checks. The tests verify all surviving keys/converted values and row
    counts with real per-block TopN feedback.
    - FE Checkstyle (zero violations), clang-format 16, BE build hygiene,
    and changed-line clang-tidy for every affected C++ file passed.
    - Existing reader tests cover exact TopK results, physical row IDs,
    localized slot mapping, missing statistics, metadata-pruning boundaries,
    ordered row-ID fetches, NULL placement, and equality boundaries.
    - FE coverage checks that potentially throwing secondary expressions do
    not disable existing TopN runtime-filter generation. The existing
    `nereids_tpch_p0/tpch/topn-filter.groovy` also exposed an actual
    regression: the blanket visitor stop removed `TOPN OPT` for `GROUP BY k
    ORDER BY k LIMIT 1`. Restore the original visitor traversal and cover
    grouped keys, aggregation with HAVING and a join, and grouping sets with
    `push_topn_to_agg=false`. These cases run full FE planning and check
    `TOPN OPT:` in the translated EXPLAIN fragments. The same unnecessary
    expression whitelist also disabled `TOPN OPT` for a nested STRUCT order
    key with an `IS NOT NULL` predicate on a sibling field, as exposed by
    `nereids_rules_p0/column_pruning/topn_filter_nested_column_pruning.groovy`.
    Additional translated-plan checks cover TIMESTAMPTZ, INT and DATE fields
    in ASC/DESC, including the complete and predicate-only nested access
    paths. Both existing regression suites and their assertions are
    unchanged. FE production logic matches the pre-PR base; only an
    explanatory traversal comment remains in the FE production diff.
    - The earlier SQL suite asserting that all excluded-row errors must be
    preserved is removed. Complete SQL performance is not claimed;
    performance evidence comes from the reader microbenchmark below.
    
    Correctness suites:
    
    ```bash
    ./run-be-ut.sh -j8 --run \
      
--filter='*Parquet*:*ColumnMapper*:*TableReader*:*Iceberg*:*EvolutionCast*:*RuntimePredicate*'
    ./run-fe-ut.sh --run \
      
org.apache.doris.nereids.postprocess.TopNRuntimeFilterTest,org.apache.doris.nereids.rules.rewrite.DateArithmeticFilterPushDownTest,org.apache.doris.nereids.postprocess.PushDownFilterThroughProjectTest,org.apache.doris.nereids.util.ExpressionUtilsTest
    ```
    
    Opt-in benchmark (use a RELEASE UT build):
    
    ```bash
    DORIS_TOPN_ROW_GROUP_BENCHMARK=1 BUILD_TYPE_UT=RELEASE \
      ./run-be-ut.sh -j8 --run 
--filter=ParquetScanTest.TopNRowGroupOrderMicrobench
    ```
    
    For paired comparison, build and save baseline/candidate UT executables
    with the same harness, then use the existing runner:
    
    ```bash
    DORIS_TOPN_ROW_GROUP_BENCHMARK=1 \
    DORIS_BE_UT_EXECUTABLE=build-support/run-query-engine-paired-benchmarks.py \
    QUERY_ENGINE_BENCH_BASELINE=./baseline/doris_be_test \
    QUERY_ENGINE_BENCH_CANDIDATE=./candidate/doris_be_test \
    QUERY_ENGINE_BENCH_CPU=0 \
    QUERY_ENGINE_BENCH_OUTPUT=./paired-results \
    BUILD_TYPE_UT=RELEASE ./run-be-ut.sh -j8 --run \
      --filter=ParquetScanTest.TopNRowGroupOrderMicrobench
    ```
    
    Select an available idle CPU for `QUERY_ENGINE_BENCH_CPU`. The runner
    emits paired timings/checksums; `PARQUET_TOPN_WORK` lines report
    per-query raw rows, groups filtered by min/max, and actual local read
    bytes.
    
    ### Release note
    
    Improve Parquet V2 TopN scans by reading promising row groups first and
    applying the latest runtime bound before opening subsequent groups.
    
    ### Check List (For Author)
    
    - Test
        - [x] Unit Test
        - [x] Manual test (reader microbenchmark and commands above)
    - Behavior changed:
    - [x] Yes. Eligible Parquet V2 TopN scans may read row groups in a
    different order and skip more groups. SQL ordering remains defined by
    the TopN operator. Expressions and conversions in excluded groups may
    not execute, so an exception from an excluded row may no longer be
    observed.
    - Does this need documentation?
        - [x] No. Internal scan optimization with no new user-facing option.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 be/src/exprs/vtopn_pred.cpp                        |  36 +++
 be/src/exprs/vtopn_pred.h                          |   6 +
 be/src/format_v2/parquet/parquet_scan.cpp          | 138 ++++++++-
 be/src/format_v2/parquet/parquet_scan.h            |   8 +
 be/test/format_v2/parquet/parquet_scan_test.cpp    | 312 ++++++++++++++++++++-
 be/test/format_v2/table/iceberg_reader_test.cpp    | 269 ++++++++++++++++++
 .../processor/post/TopnFilterPushDownVisitor.java  |   2 +
 .../nereids/postprocess/TopNRuntimeFilterTest.java | 101 +++++++
 8 files changed, 867 insertions(+), 5 deletions(-)

diff --git a/be/src/exprs/vtopn_pred.cpp b/be/src/exprs/vtopn_pred.cpp
index ab7ac572b85..0ef432cde95 100644
--- a/be/src/exprs/vtopn_pred.cpp
+++ b/be/src/exprs/vtopn_pred.cpp
@@ -97,6 +97,42 @@ bool topn_string_matches(int comparison, RawComparisonOp op) 
{
 
 } // namespace
 
+bool VTopNPred::can_evaluate_zonemap_filter() const {
+    // Capability must remain available before the first bound; readers cache 
it during open.
+    return _predicate != nullptr && _children.size() == 1 && 
_children[0]->is_slot_ref();
+}
+
+ZoneMapFilterResult VTopNPred::evaluate_zonemap_filter(const 
ZoneMapEvalContext& ctx) const {
+    if (!can_evaluate_zonemap_filter()) {
+        return unsupported_zonemap_filter(ctx);
+    }
+    const Field bound = _predicate->get_value();
+    if (bound.is_null()) {
+        return ZoneMapFilterResult::kMayMatch;
+    }
+    const auto* slot = assert_cast<const VSlotRef*>(_children[0].get());
+    const auto zone = ctx.zone_map(slot->column_id());
+    const auto type = ctx.data_type(slot->column_id());
+    if (zone == nullptr || !expr_zonemap::data_types_compatible(type, 
slot->data_type())) {
+        return unsupported_zonemap_filter(ctx);
+    }
+    if (_predicate->nulls_first() && zone->has_null) {
+        return ZoneMapFilterResult::kMayMatch;
+    }
+    if (!zone->has_not_null) {
+        return ZoneMapFilterResult::kNoMatch;
+    }
+    if (ctx.floating_nan_count_unknown(slot->column_id()) || bound.is_nan() ||
+        !expr_zonemap::range_stats_usable_for_zonemap(*zone, type) ||
+        !expr_zonemap::field_types_compatible(bound.get_type(),
+                                              
remove_nullable(type)->get_primitive_type())) {
+        return unsupported_zonemap_filter(ctx);
+    }
+    // Keep equality: with ORDER BY a, b a row tied on a can still improve b.
+    const bool excluded = _predicate->is_asc() ? bound < zone->min_value : 
zone->max_value < bound;
+    return excluded ? ZoneMapFilterResult::kNoMatch : 
ZoneMapFilterResult::kMayMatch;
+}
+
 bool VTopNPred::can_execute_on_raw_fixed_values(const DataTypePtr& data_type, 
int column_id) const {
     if (_predicate == nullptr || data_type == nullptr || _children.size() != 
1) {
         return false;
diff --git a/be/src/exprs/vtopn_pred.h b/be/src/exprs/vtopn_pred.h
index af680955d05..612e411427f 100644
--- a/be/src/exprs/vtopn_pred.h
+++ b/be/src/exprs/vtopn_pred.h
@@ -63,6 +63,12 @@ public:
     }
 
     int source_node_id() const { return _source_node_id; }
+    bool is_asc() const { return _predicate->is_asc(); }
+    bool nulls_first() const { return _predicate->nulls_first(); }
+
+    bool can_evaluate_zonemap_filter() const override;
+    ZoneMapFilterResult evaluate_zonemap_filter(const ZoneMapEvalContext& ctx) 
const override;
+
     Status clone_node(VExprSPtr* cloned_expr) const override {
         DORIS_CHECK(cloned_expr != nullptr);
         *cloned_expr = VTopNPred::create_shared(clone_texpr_node(), 
_source_node_id, nullptr);
diff --git a/be/src/format_v2/parquet/parquet_scan.cpp 
b/be/src/format_v2/parquet/parquet_scan.cpp
index e24c3bc5812..4ecea4ab08e 100644
--- a/be/src/format_v2/parquet/parquet_scan.cpp
+++ b/be/src/format_v2/parquet/parquet_scan.cpp
@@ -20,6 +20,7 @@
 #include <iterator>
 #include <limits>
 #include <memory>
+#include <numeric>
 #include <optional>
 #include <ranges>
 #include <set>
@@ -43,6 +44,7 @@
 #include "exprs/vcompound_pred.h"
 #include "exprs/vectorized_fn_call.h"
 #include "exprs/vexpr_context.h"
+#include "exprs/vtopn_pred.h"
 #include "format_v2/parquet/parquet_column_schema.h"
 #include "format_v2/parquet/parquet_file_context.h"
 #include "format_v2/parquet/parquet_statistics.h"
@@ -1230,6 +1232,9 @@ void 
ParquetScanScheduler::set_condition_cache_context(std::shared_ptr<Condition
 
 void ParquetScanScheduler::reset() {
     _next_row_group_plan_idx = 0;
+    _row_group_order.clear();
+    _row_group_order_prepared = false;
+    _topn_footer_recheck = false;
     _raw_rows_read = 0;
     _predicate_schedule_request = nullptr;
     _predicate_schedule = {};
@@ -1409,6 +1414,125 @@ std::vector<format::LocalColumnIndex> 
ParquetScanScheduler::adaptive_predicate_p
     return result;
 }
 
+namespace {
+
+struct TopNOrderingColumn {
+    const VTopNPred* predicate;
+    const ParquetColumnSchema* schema;
+};
+
+std::optional<TopNOrderingColumn> find_topn_ordering_column(
+        const std::vector<std::unique_ptr<ParquetColumnSchema>>& file_schema,
+        const format::FileScanRequest& request) {
+    const size_t safe_count =
+            std::min(request.metadata_pruning_safe_conjunct_count, 
request.conjuncts.size());
+    const VTopNPred* topn = nullptr;
+    for (size_t idx = 0; idx < safe_count; ++idx) {
+        const auto& root = request.conjuncts[idx]->root();
+        if (root->is_topn_filter() && root->can_evaluate_zonemap_filter()) {
+            topn = assert_cast<const VTopNPred*>(root.get());
+            break;
+        }
+    }
+    if (topn == nullptr) {
+        return std::nullopt;
+    }
+    const auto* slot = assert_cast<const VSlotRef*>(topn->get_child(0).get());
+    const auto position = std::ranges::find_if(request.local_positions, 
[&](const auto& entry) {
+        return entry.second.value() == slot->column_id();
+    });
+    if (position == request.local_positions.end()) {
+        return std::nullopt;
+    }
+    const auto column = std::ranges::find_if(file_schema, [&](const auto& 
schema) {
+        return schema->local_id == position->first.value();
+    });
+    if (column == file_schema.end() || (*column)->kind != 
ParquetColumnSchemaKind::PRIMITIVE ||
+        (*column)->max_repetition_level != 0 ||
+        !expr_zonemap::data_types_compatible((*column)->type, 
slot->data_type())) {
+        return std::nullopt;
+    }
+    return TopNOrderingColumn {.predicate = topn, .schema = column->get()};
+}
+
+} // namespace
+
+void ParquetScanScheduler::prepare_topn_row_group_order(
+        const ParquetFileContext& file_context,
+        const std::vector<std::unique_ptr<ParquetColumnSchema>>& file_schema,
+        const format::FileScanRequest& request) {
+    _row_group_order_prepared = true;
+    const auto binding = find_topn_ordering_column(file_schema, request);
+    if (!binding.has_value()) {
+        return;
+    }
+    const auto* topn = binding->predicate;
+    const auto& schema = *binding->schema;
+    const size_t safe_count =
+            std::min(request.metadata_pruning_safe_conjunct_count, 
request.conjuncts.size());
+    const auto& metadata = file_context.native_metadata->to_thrift();
+    _topn_footer_recheck = std::ranges::any_of(_scan_plan->row_groups, 
[&](const auto& plan) {
+        const auto& group = metadata.row_groups[plan.row_group_id];
+        DORIS_CHECK(schema.leaf_column_id >= 0 && schema.leaf_column_id < 
group.columns.size());
+        const auto& chunk = group.columns[schema.leaf_column_id];
+        return chunk.__isset.meta_data && chunk.meta_data.__isset.statistics;
+    });
+    // Scheduling need not evaluate conversions in groups excluded by TopN. 
Reuse the request's
+    // existing metadata-pruning boundary; row-ID fetches still require 
increasing file positions.
+    if (!_topn_footer_recheck || request.row_ids.has_value() ||
+        safe_count != request.conjuncts.size() || 
_scan_plan->row_groups.size() < 2) {
+        return;
+    }
+    struct Priority {
+        std::optional<Field> best;
+        bool may_have_null = false;
+    };
+    std::vector<Priority> priorities;
+    priorities.reserve(_scan_plan->row_groups.size());
+    const bool trusted_nulls =
+            native::parquet_reader_compat(metadata.__isset.created_by ? 
metadata.created_by : "")
+                    .null_count_trusted;
+    bool usable = false;
+    for (const auto& plan : _scan_plan->row_groups) {
+        const auto& group = metadata.row_groups[plan.row_group_id];
+        DORIS_CHECK(schema.leaf_column_id >= 0 && schema.leaf_column_id < 
group.columns.size());
+        const auto& chunk = group.columns[schema.leaf_column_id];
+        Priority priority;
+        if (chunk.__isset.meta_data && chunk.meta_data.__isset.statistics) {
+            const auto safe_statistics = 
detail::sanitize_native_footer_statistics(
+                    schema.type_descriptor, chunk.meta_data.statistics,
+                    detail::has_supported_type_defined_order(metadata, 
schema.leaf_column_id));
+            const auto stats = 
ParquetStatisticsUtils::TransformColumnStatistics(
+                    schema, &safe_statistics, chunk.meta_data.num_values, 
_timezone, trusted_nulls);
+            if (stats.has_min_max) {
+                priority.best = topn->is_asc() ? stats.min_value : 
stats.max_value;
+                usable = true;
+            }
+            priority.may_have_null = stats.has_null;
+        }
+        priorities.push_back(std::move(priority));
+    }
+    if (!usable) {
+        return;
+    }
+    _row_group_order.resize(priorities.size());
+    std::iota(_row_group_order.begin(), _row_group_order.end(), 0);
+    std::stable_sort(_row_group_order.begin(), _row_group_order.end(), 
[&](size_t lhs, size_t rhs) {
+        const auto& left = priorities[lhs];
+        const auto& right = priorities[rhs];
+        if (topn->nulls_first() && left.may_have_null != right.may_have_null) {
+            return left.may_have_null;
+        }
+        if (left.best.has_value() != right.best.has_value()) {
+            return left.best.has_value();
+        }
+        if (!left.best.has_value()) {
+            return false;
+        }
+        return topn->is_asc() ? *left.best < *right.best : *right.best < 
*left.best;
+    });
+}
+
 Status ParquetScanScheduler::open_next_row_group(
         ParquetFileContext& file_context,
         const std::vector<std::unique_ptr<ParquetColumnSchema>>& file_schema,
@@ -1416,6 +1540,9 @@ Status ParquetScanScheduler::open_next_row_group(
     *has_row_group = false;
     RowGroupReadPlan* selected_plan = nullptr;
     DORIS_CHECK(_scan_plan != nullptr);
+    if (!_row_group_order_prepared) {
+        prepare_topn_row_group_order(file_context, file_schema, request);
+    }
     if (_requested_leaf_ids_need_refresh) {
         // A runtime-filter refresh changes the immutable projection baseline 
exactly once; later
         // row groups only apply their full-Variant fallback delta to this 
rebuilt set.
@@ -1423,7 +1550,11 @@ Status ParquetScanScheduler::open_next_row_group(
         _requested_leaf_ids_need_refresh = false;
     }
     while (_next_row_group_plan_idx < _scan_plan->row_groups.size()) {
-        RowGroupReadPlan& candidate_plan = 
_scan_plan->row_groups[_next_row_group_plan_idx++];
+        const size_t plan_idx = _row_group_order.empty()
+                                        ? _next_row_group_plan_idx
+                                        : 
_row_group_order[_next_row_group_plan_idx];
+        ++_next_row_group_plan_idx;
+        RowGroupReadPlan& candidate_plan = _scan_plan->row_groups[plan_idx];
         // Probe only the row group about to execute. This keeps 
LIMIT/cancellation latency
         // independent of the number of later remote row groups while 
preserving eager footer
         // statistics pruning during open.
@@ -1440,6 +1571,11 @@ Status ParquetScanScheduler::open_next_row_group(
             const auto& row_group = file_context.native_metadata->to_thrift()
                                             
.row_groups[candidate_plan.row_group_id];
             prepare_row_group_physical_projection(row_group, file_schema, 
request, &candidate_plan);
+        }
+        // TopN changes its bound in place, without publishing a new 
FileScanRequest. For example,
+        // reading [0, 99] first can tighten ASC LIMIT 3 to 2 and exclude a 
later group [100, 199].
+        // Recheck before dictionary/index probes or prefetch, using the 
current monotonic bound.
+        if (_remaining_plans_need_replanning || _topn_footer_recheck) {
             const auto row_group_leaf_ids = row_group_leaf_column_ids(
                     file_schema, request, candidate_plan, 
_scan_plan->requested_leaf_column_ids);
             const auto& requested_leaf_ids = row_group_leaf_ids.has_value()
diff --git a/be/src/format_v2/parquet/parquet_scan.h 
b/be/src/format_v2/parquet/parquet_scan.h
index b8c2d80c057..a4220304286 100644
--- a/be/src/format_v2/parquet/parquet_scan.h
+++ b/be/src/format_v2/parquet/parquet_scan.h
@@ -239,6 +239,10 @@ private:
     static constexpr size_t PROFILE_FLUSH_BATCH_INTERVAL = 16;
 
     void reset_current_row_group();
+    void prepare_topn_row_group_order(
+            const ParquetFileContext& file_context,
+            const std::vector<std::unique_ptr<ParquetColumnSchema>>& 
file_schema,
+            const format::FileScanRequest& request);
     void activate_pending_scan_request_at_row_group_boundary();
     void flush_current_reader_profiles();
     bool finish_current_reader_batch_profiles();
@@ -287,6 +291,10 @@ private:
 
     std::shared_ptr<RowGroupScanPlan> _scan_plan; // shared with the reader's 
aggregate path
     size_t _next_row_group_plan_idx = 0;          // index of the next row 
group to process
+    // Execution order is separate from physical plans: Condition Cache uses 
their file-row order.
+    std::vector<size_t> _row_group_order;
+    bool _row_group_order_prepared = false;
+    bool _topn_footer_recheck = false;
 
     bool _has_current_row_group = false;
     // Readers retain pointers into this immutable row-group map, so it must 
outlive both maps below.
diff --git a/be/test/format_v2/parquet/parquet_scan_test.cpp 
b/be/test/format_v2/parquet/parquet_scan_test.cpp
index c8ac9506387..af4a6fe0633 100644
--- a/be/test/format_v2/parquet/parquet_scan_test.cpp
+++ b/be/test/format_v2/parquet/parquet_scan_test.cpp
@@ -24,6 +24,7 @@
 #include <parquet/api/writer.h>
 #include <parquet/arrow/writer.h>
 #include <parquet/encoding.h>
+#include <unistd.h>
 
 #include <atomic>
 #include <bit>
@@ -36,6 +37,7 @@
 #include <memory>
 #include <numeric>
 #include <optional>
+#include <queue>
 #include <set>
 #include <string>
 #include <thread>
@@ -43,6 +45,7 @@
 #include <vector>
 
 #include "common/config.h"
+#include "common/metrics/doris_metrics.h"
 #include "core/assert_cast.h"
 #include "core/block/block.h"
 #include "core/column/column_array.h"
@@ -85,6 +88,7 @@
 #include "storage/index/zone_map/zonemap_eval_context.h"
 #include "storage/index/zone_map/zonemap_filter_result.h"
 #include "storage/utils.h"
+#include "testutil/benchmark_control.h"
 #include "testutil/mock/mock_query_context.h"
 #include "util/coding.h"
 #include "util/defer_op.h"
@@ -961,7 +965,8 @@ struct PreparedTopNConjunct {
 
 PreparedTopNConjunct create_topn_conjunct(RuntimeState* state, int column_id,
                                           const DataTypePtr& data_type, const 
Field& bound,
-                                          bool publish_bound = true) {
+                                          bool publish_bound = true, bool 
is_asc = true,
+                                          bool nulls_first = false) {
     DORIS_CHECK(state != nullptr);
     DORIS_CHECK(data_type != nullptr);
     TExpr target_expr;
@@ -977,8 +982,8 @@ PreparedTopNConjunct create_topn_conjunct(RuntimeState* 
state, int column_id,
 
     TTopnFilterDesc desc;
     desc.__set_source_node_id(10);
-    desc.__set_is_asc(true);
-    desc.__set_null_first(false);
+    desc.__set_is_asc(is_asc);
+    desc.__set_null_first(nulls_first);
     desc.__set_target_node_id_to_target_expr({{20, std::move(target_expr)}});
     auto query_context = MockQueryContext::create();
     state->_query_ctx = query_context.get();
@@ -1927,7 +1932,9 @@ void use_schema_order_positions(format::FileScanRequest* 
request,
 class ParquetScanTest : public testing::Test {
 protected:
     void SetUp() override {
-        _test_dir = std::filesystem::temp_directory_path() / 
"doris_format_v2_parquet_scan_test";
+        // Paired baseline/candidate benchmarks must not remove the other 
process's fixtures.
+        _test_dir = std::filesystem::temp_directory_path() /
+                    ("doris_format_v2_parquet_scan_test_" + 
std::to_string(getpid()));
         std::filesystem::remove_all(_test_dir);
         std::filesystem::create_directories(_test_dir);
         _file_path = (_test_dir / "scan.parquet").string();
@@ -4790,5 +4797,302 @@ TEST_F(ParquetScanTest, 
OpenDefersPageIndexProbeToCurrentRowGroup) {
     EXPECT_EQ(counter_value(profile, "PageIndexReadCalls"), 1);
 }
 
+TEST_F(ParquetScanTest, TopNReadsBestRowGroupBeforePublishingBound) {
+    constexpr int GROUP_ROWS = 128;
+    constexpr int GROUPS = 4;
+    constexpr int K = 3;
+    for (const bool ascending : {true, false}) {
+        _file_path = (_test_dir / (ascending ? "asc.parquet" : 
"desc.parquet")).string();
+        std::vector<int32_t> values;
+        for (int group = 0; group < GROUPS; ++group) {
+            const int base = (ascending ? GROUPS - group - 1 : group) * 
GROUP_ROWS;
+            for (int row = 0; row < GROUP_ROWS; ++row) {
+                values.push_back(base + row);
+            }
+        }
+        auto table = arrow::Table::Make(arrow::schema({arrow::field("k", 
arrow::int32(), false)}),
+                                        {build_int32_array(values)});
+        // PLAIN, no dictionary or PageIndex: only Footer pruning can avoid 
reading later groups.
+        write_table(_file_path, table, GROUP_ROWS, false, false, true);
+        RuntimeProfile profile("topn_row_group_order");
+        const format::GlobalRowIdContext rowid_context {
+                .version = 1, .backend_id = 2, .file_id = 3};
+        auto reader = create_reader(0, -1, &profile, rowid_context);
+        RuntimeState state {TQueryOptions(), TQueryGlobals()};
+        ASSERT_TRUE(reader->init(&state).ok());
+        reader->set_batch_size(GROUP_ROWS);
+        std::vector<format::ColumnDefinition> schema;
+        ASSERT_TRUE(reader->get_schema(&schema).ok());
+        auto prepared = create_topn_conjunct(&state, 0, schema[0].type, Field 
{}, false, ascending);
+        auto request = std::make_shared<format::FileScanRequest>();
+        format::FileScanRequestBuilder builder(request.get());
+        
ASSERT_TRUE(builder.add_predicate_column(format::LocalColumnId(0)).ok());
+        ASSERT_TRUE(builder.add_non_predicate_column(
+                                   
format::LocalColumnId(format::GLOBAL_ROWID_COLUMN_ID))
+                            .ok());
+        request->conjuncts.push_back(prepared.conjunct);
+        ASSERT_TRUE(reader->open(request).ok());
+        std::vector<int32_t> top;
+        bool eof = false;
+        while (!eof) {
+            Block block = build_file_block(schema);
+            size_t rows = 0;
+            ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+            const auto& column = 
int32_data_column(*block.get_by_position(0).column);
+            for (size_t row = 0; row < rows; ++row) {
+                const int32_t value = column.get_element(row);
+                top.push_back(value);
+                const auto location =
+                        
decode_rowid(string_data_column(*block.get_by_position(1).column), row);
+                const int expected_position =
+                        ascending ? (GROUPS - 1 - value / GROUP_ROWS) * 
GROUP_ROWS +
+                                            value % GROUP_ROWS
+                                  : value;
+                EXPECT_EQ(location.row_id, expected_position);
+                EXPECT_EQ(location.file_id, rowid_context.file_id);
+            }
+            std::ranges::sort(
+                    top, [ascending](int32_t a, int32_t b) { return ascending 
? a < b : a > b; });
+            if (top.size() >= K) {
+                top.resize(K);
+                // Model the SortSink feedback after each block without 
replacing the scan request.
+                ASSERT_TRUE(prepared.query_context->get_runtime_predicate(10)
+                                    
.update(Field::create_field<TYPE_INT>(top.back()))
+                                    .ok());
+            }
+        }
+        const std::vector<int32_t> expected =
+                ascending ? std::vector<int32_t> {0, 1, 2} : 
std::vector<int32_t> {511, 510, 509};
+        EXPECT_EQ(top, expected);
+        EXPECT_EQ(counter_value(profile, "RawRowsRead"), GROUP_ROWS);
+        EXPECT_EQ(counter_value(profile, "RowGroupsFilteredByMinMax"), GROUPS 
- 1);
+        prepared.conjunct->close();
+    }
+}
+
+TEST_F(ParquetScanTest, 
TopNOrderUsesLocalizedSlotInsteadOfPhysicalColumnOrdinal) {
+    auto table = arrow::Table::Make(
+            arrow::schema({arrow::field("padding", arrow::int32(), false),
+                           arrow::field("k", arrow::int32(), false)}),
+            {build_int32_array({-1, -1, -1, -1, -1, -1}), 
build_int32_array({8, 9, 4, 5, 0, 1})});
+    write_table(_file_path, table, 2, false, false, true);
+    RuntimeProfile profile("localized_topn");
+    RuntimeState state {TQueryOptions(), TQueryGlobals()};
+    auto reader = create_reader(0, -1, &profile);
+    ASSERT_TRUE(reader->init(&state).ok());
+    reader->set_batch_size(2);
+    std::vector<format::ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    // The physical key is column 1, but the mapper binds it to slot 0 in the 
file-local block.
+    auto prepared = create_topn_conjunct(&state, 0, schema[1].type, Field {}, 
false);
+    auto request = std::make_shared<format::FileScanRequest>();
+    format::FileScanRequestBuilder builder(request.get());
+    ASSERT_TRUE(builder.add_predicate_column(format::LocalColumnId(1)).ok());
+    request->conjuncts.push_back(prepared.conjunct);
+    ASSERT_TRUE(reader->open(request).ok());
+    Block block = build_file_block({schema[1]});
+    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_data(),
+              (ColumnInt32::Container {0, 1}));
+    ASSERT_TRUE(prepared.query_context->get_runtime_predicate(10)
+                        .update(Field::create_field<TYPE_INT>(1))
+                        .ok());
+    block.clear_column_data();
+    ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+    EXPECT_TRUE(eof);
+    EXPECT_EQ(rows, 0);
+    EXPECT_EQ(counter_value(profile, "RawRowsRead"), 2);
+    EXPECT_EQ(counter_value(profile, "RowGroupsFilteredByMinMax"), 2);
+    prepared.conjunct->close();
+}
+
+TEST_F(ParquetScanTest, TopNMissingStatisticsAndPruningFenceKeepPhysicalOrder) 
{
+    for (const int scenario : {0, 1, 2}) {
+        auto table = arrow::Table::Make(arrow::schema({arrow::field("k", 
arrow::int32(), false)}),
+                                        {build_int32_array({8, 9, 4, 5, 0, 
1})});
+        write_table(_file_path, table, 2, false, false, scenario != 0);
+        RuntimeState state {TQueryOptions(), TQueryGlobals()};
+        auto reader = create_reader();
+        ASSERT_TRUE(reader->init(&state).ok());
+        reader->set_batch_size(2);
+        std::vector<format::ColumnDefinition> schema;
+        ASSERT_TRUE(reader->get_schema(&schema).ok());
+        auto prepared = create_topn_conjunct(&state, 0, schema[0].type, Field 
{}, false);
+        auto request = std::make_shared<format::FileScanRequest>();
+        format::FileScanRequestBuilder builder(request.get());
+        
ASSERT_TRUE(builder.add_predicate_column(format::LocalColumnId(0)).ok());
+        request->conjuncts.push_back(prepared.conjunct);
+        if (scenario == 1) {
+            // A preceding evaluation-sensitive conjunct prevents metadata 
pruning and reordering.
+            request->metadata_pruning_safe_conjunct_count = 0;
+        } else if (scenario == 2) {
+            request->row_ids = std::vector<int64_t> {0, 3, 4};
+        }
+        ASSERT_TRUE(reader->open(request).ok());
+        std::vector<int32_t> actual;
+        bool eof = false;
+        while (!eof) {
+            Block block = build_file_block(schema);
+            size_t rows = 0;
+            ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+            const auto& column = 
int32_data_column(*block.get_by_position(0).column);
+            for (size_t row = 0; row < rows; ++row) {
+                actual.push_back(column.get_element(row));
+            }
+        }
+        EXPECT_EQ(actual, scenario == 2 ? (std::vector<int32_t> {8, 5, 0})
+                                        : (std::vector<int32_t> {8, 9, 4, 5, 
0, 1}));
+        prepared.conjunct->close();
+    }
+}
+
+TEST_F(ParquetScanTest, TopNZoneMapObservesLateBoundsAndNullOrdering) {
+    for (const bool ascending : {true, false}) {
+        for (const bool nulls_first : {true, false}) {
+            RuntimeState state {TQueryOptions(), TQueryGlobals()};
+            auto type = make_nullable(std::make_shared<DataTypeInt32>());
+            auto prepared =
+                    create_topn_conjunct(&state, 0, type, Field {}, false, 
ascending, nulls_first);
+            auto zone = std::make_shared<segment_v2::ZoneMap>();
+            zone->has_not_null = true;
+            zone->min_value = Field::create_field<TYPE_INT>(10);
+            zone->max_value = Field::create_field<TYPE_INT>(20);
+            ZoneMapEvalContext ctx;
+            ctx.slots[0].data_type = type;
+            ctx.slots[0].zone_map = zone;
+            const auto& expression = prepared.conjunct->root();
+            EXPECT_TRUE(expression->can_evaluate_zonemap_filter());
+            EXPECT_NE(expression->evaluate_zonemap_filter(ctx), 
ZoneMapFilterResult::kNoMatch);
+            ASSERT_TRUE(prepared.query_context->get_runtime_predicate(10)
+                                
.update(Field::create_field<TYPE_INT>(ascending ? 9 : 21))
+                                .ok());
+            EXPECT_EQ(expression->evaluate_zonemap_filter(ctx), 
ZoneMapFilterResult::kNoMatch);
+            // Equal leading keys may still win on a later ORDER BY key and 
must remain candidates.
+            if (ascending) {
+                zone->min_value = Field::create_field<TYPE_INT>(9);
+            } else {
+                zone->max_value = Field::create_field<TYPE_INT>(21);
+            }
+            EXPECT_EQ(expression->evaluate_zonemap_filter(ctx), 
ZoneMapFilterResult::kMayMatch);
+            zone->min_value = Field::create_field<TYPE_INT>(10);
+            zone->max_value = Field::create_field<TYPE_INT>(20);
+            zone->has_null = true;
+            EXPECT_EQ(expression->evaluate_zonemap_filter(ctx),
+                      nulls_first ? ZoneMapFilterResult::kMayMatch : 
ZoneMapFilterResult::kNoMatch);
+            zone->has_not_null = false;
+            EXPECT_EQ(expression->evaluate_zonemap_filter(ctx),
+                      nulls_first ? ZoneMapFilterResult::kMayMatch : 
ZoneMapFilterResult::kNoMatch);
+            ctx.slots.clear();
+            EXPECT_EQ(expression->evaluate_zonemap_filter(ctx), 
ZoneMapFilterResult::kUnsupported);
+            prepared.conjunct->close();
+        }
+    }
+}
+
+TEST_F(ParquetScanTest, TopNRowGroupOrderMicrobench) {
+    if (std::getenv("DORIS_TOPN_ROW_GROUP_BENCHMARK") == nullptr) {
+        GTEST_SKIP() << "Set DORIS_TOPN_ROW_GROUP_BENCHMARK=1 to run the 
reader microbenchmark";
+    }
+    constexpr int GROUPS = 128;
+    constexpr int GROUP_ROWS = 4096;
+    constexpr int K = 10;
+    constexpr int SAMPLES = 9;
+    constexpr int ITERATIONS = 32;
+    for (const bool ascending : {true, false}) {
+        _file_path = (_test_dir / (ascending ? "asc.parquet" : 
"desc.parquet")).string();
+        std::vector<int32_t> values;
+        values.reserve(GROUPS * GROUP_ROWS);
+        for (int group = 0; group < GROUPS; ++group) {
+            const int base = (ascending ? GROUPS - group - 1 : group) * 
GROUP_ROWS;
+            for (int row = 0; row < GROUP_ROWS; ++row) {
+                values.push_back(base + row);
+            }
+        }
+        auto table = arrow::Table::Make(arrow::schema({arrow::field("k", 
arrow::int32(), false)}),
+                                        {build_int32_array(values)});
+        // Fixture generation and the warmup are outside the reported 
reader-plus-TopK samples.
+        write_table(_file_path, table, GROUP_ROWS, false, false, true);
+        for (int sample = -1; sample < SAMPLES; ++sample) {
+            const std::string label = ascending ? "parquet_topn_asc" : 
"parquet_topn_desc";
+            if (sample >= 0) {
+                benchmark::wait_for_turn(label, sample);
+            }
+            uint64_t total_elapsed = 0;
+            int64_t total_checksum = 0;
+            int64_t total_rows = 0;
+            int64_t total_filtered = 0;
+            int64_t total_bytes = 0;
+            for (int iteration = 0; iteration < ITERATIONS; ++iteration) {
+                const auto bytes_before = 
DorisMetrics::instance()->local_bytes_read_total->value();
+                const auto start = std::chrono::steady_clock::now();
+                RuntimeProfile profile("topn_microbench");
+                RuntimeState state {TQueryOptions(), TQueryGlobals()};
+                auto reader = create_reader(0, -1, &profile);
+                ASSERT_TRUE(reader->init(&state).ok());
+                reader->set_batch_size(GROUP_ROWS);
+                std::vector<format::ColumnDefinition> schema;
+                ASSERT_TRUE(reader->get_schema(&schema).ok());
+                auto prepared =
+                        create_topn_conjunct(&state, 0, schema[0].type, Field 
{}, false, ascending);
+                auto request = std::make_shared<format::FileScanRequest>();
+                format::FileScanRequestBuilder builder(request.get());
+                
ASSERT_TRUE(builder.add_predicate_column(format::LocalColumnId(0)).ok());
+                request->conjuncts.push_back(prepared.conjunct);
+                ASSERT_TRUE(reader->open(request).ok());
+                auto compare = [ascending](int32_t a, int32_t b) {
+                    return ascending ? a < b : a > b;
+                };
+                std::priority_queue<int32_t, std::vector<int32_t>, 
decltype(compare)> top(compare);
+                bool eof = false;
+                while (!eof) {
+                    Block block = build_file_block(schema);
+                    size_t rows = 0;
+                    ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+                    const auto& column = 
int32_data_column(*block.get_by_position(0).column);
+                    for (size_t row = 0; row < rows; ++row) {
+                        top.push(column.get_element(row));
+                        if (top.size() > K) {
+                            top.pop();
+                        }
+                    }
+                    if (top.size() == K) {
+                        
ASSERT_TRUE(prepared.query_context->get_runtime_predicate(10)
+                                            
.update(Field::create_field<TYPE_INT>(top.top()))
+                                            .ok());
+                    }
+                }
+                ASSERT_TRUE(reader->close().ok());
+                const auto elapsed = 
std::chrono::duration_cast<std::chrono::nanoseconds>(
+                                             std::chrono::steady_clock::now() 
- start)
+                                             .count();
+                ASSERT_EQ(top.size(), K);
+                int64_t checksum = 0;
+                for (int rank = K - 1; rank >= 0; --rank) {
+                    EXPECT_EQ(top.top(), ascending ? rank : GROUPS * 
GROUP_ROWS - 1 - rank);
+                    checksum += top.top();
+                    top.pop();
+                }
+                total_elapsed += elapsed;
+                total_checksum += checksum;
+                total_rows += counter_value(profile, "RawRowsRead");
+                total_filtered += counter_value(profile, 
"RowGroupsFilteredByMinMax");
+                total_bytes +=
+                        
DorisMetrics::instance()->local_bytes_read_total->value() - bytes_before;
+                prepared.conjunct->close();
+            }
+            if (sample >= 0) {
+                benchmark::report_sample(label, sample, ITERATIONS, 
total_elapsed, total_checksum,
+                                         true);
+                std::cout << "PARQUET_TOPN_WORK," << label << ',' << sample << 
','
+                          << total_rows / ITERATIONS << ',' << total_filtered 
/ ITERATIONS << ','
+                          << total_bytes / ITERATIONS << std::endl;
+            }
+        }
+    }
+}
+
 } // namespace
 } // namespace doris
diff --git a/be/test/format_v2/table/iceberg_reader_test.cpp 
b/be/test/format_v2/table/iceberg_reader_test.cpp
index d905b8cb726..2651501c62e 100644
--- a/be/test/format_v2/table/iceberg_reader_test.cpp
+++ b/be/test/format_v2/table/iceberg_reader_test.cpp
@@ -72,6 +72,7 @@
 #include "exprs/vexpr.h"
 #include "exprs/vliteral.h"
 #include "exprs/vslot_ref.h"
+#include "exprs/vtopn_pred.h"
 #include "format/format_common.h"
 #include "format/orc/orc_memory_stream_test.h"
 #include "format/table/deletion_vector_reader.h"
@@ -85,7 +86,9 @@
 #include "runtime/runtime_profile.h"
 #include "runtime/runtime_state.h"
 #include "storage/segment/condition_cache.h"
+#include "testutil/mock/mock_query_context.h"
 #include "util/debug_points.h"
+#include "util/defer_op.h"
 #include "util/hash_util.hpp"
 #include "util/url_coding.h"
 
@@ -1726,6 +1729,272 @@ void apply_final_conjuncts(Block* block, const 
VExprContextSPtrs& conjuncts) {
     ASSERT_TRUE(status.ok()) << status;
 }
 
+struct EvolutionTopNFilter {
+    std::shared_ptr<MockQueryContext> query_context;
+    VExprContextSPtr conjunct;
+};
+
+EvolutionTopNFilter make_evolution_topn_filter(RuntimeState* state, bool 
ascending) {
+    const auto type = make_nullable(std::make_shared<DataTypeInt32>());
+    TExpr target;
+    TExprNode slot;
+    slot.__set_node_type(TExprNodeType::SLOT_REF);
+    slot.__set_type(type->to_thrift());
+    slot.__set_num_children(0);
+    TSlotRef slot_ref;
+    slot_ref.__set_slot_id(0);
+    slot_ref.__set_tuple_id(0);
+    slot.__set_slot_ref(slot_ref);
+    target.nodes.push_back(slot);
+    TTopnFilterDesc desc;
+    desc.__set_source_node_id(10);
+    desc.__set_is_asc(ascending);
+    desc.__set_null_first(false);
+    desc.__set_target_node_id_to_target_expr({{20, target}});
+    auto query = MockQueryContext::create();
+    state->_query_ctx = query.get();
+    query->init_runtime_predicates({desc});
+    auto& predicate = query->get_runtime_predicate(10);
+    predicate.set_detected_source();
+    DORIS_CHECK(predicate.init_target(20, {}, -1).ok());
+    TExprNode topn_node;
+    topn_node.__set_type(std::make_shared<DataTypeUInt8>()->to_thrift());
+    topn_node.__set_is_nullable(true);
+    auto topn = VTopNPred::create_shared(topn_node, 10, nullptr);
+    topn->add_child(VSlotRef::create_shared(0, 0, -1, type, "k"));
+    return {.query_context = std::move(query), .conjunct = 
prepared_conjunct(state, topn)};
+}
+
+void write_topn_evolution_file(const std::string& path, bool ascending, bool 
nested,
+                               int32_t bad_date = -354285) {
+    constexpr int GROUP_ROWS = 4096;
+    arrow::Int32Builder keys;
+    arrow::Date32Builder dates;
+    for (int row = 0; row < GROUP_ROWS * 4; ++row) {
+        const bool bad = row < GROUP_ROWS;
+        const int key = (bad == ascending) ? 100 : 0;
+        DORIS_CHECK(keys.Append(key).ok());
+        DORIS_CHECK(dates.Append(bad ? bad_date : 19724).ok());
+    }
+    auto key_field = arrow::field("k", arrow::int32(), true,
+                                  
arrow::key_value_metadata({"PARQUET:field_id"}, {"1"}));
+    auto date_field = arrow::field("ts", arrow::date32(), true,
+                                   
arrow::key_value_metadata({"PARQUET:field_id"}, {"2"}));
+    std::shared_ptr<arrow::Array> date_values = dates.Finish().ValueOrDie();
+    if (nested) {
+        date_values = arrow::StructArray::Make({date_values}, 
{date_field}).ValueOrDie();
+        date_field = arrow::field("outer", date_values->type(), true,
+                                  
arrow::key_value_metadata({"PARQUET:field_id"}, {"3"}));
+    }
+    auto table = arrow::Table::Make(arrow::schema({key_field, date_field}),
+                                    {keys.Finish().ValueOrDie(), date_values});
+    auto out = arrow::io::FileOutputStream::Open(path).ValueOrDie();
+    ::parquet::WriterProperties::Builder properties;
+    properties.disable_dictionary();
+    properties.data_page_version(::parquet::ParquetDataPageVersion::V2);
+    DORIS_CHECK(::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), out, GROUP_ROWS,
+                                             properties.build())
+                        .ok());
+    DORIS_CHECK(out->Close().ok());
+}
+
+std::vector<ColumnDefinition> topn_evolution_columns(bool promote, bool 
nested) {
+    DataTypePtr date_type = promote ? 
DataTypePtr(std::make_shared<DataTypeTimeStampNs>())
+                                    : 
DataTypePtr(std::make_shared<DataTypeDateV2>());
+    auto date = make_table_column(2, "ts", date_type);
+    if (nested) {
+        auto outer = make_table_column(
+                3, "outer",
+                std::make_shared<DataTypeStruct>(DataTypes {date.type}, 
Strings {"ts"}));
+        outer.children = {date};
+        date = outer;
+    }
+    return {make_table_column(1, "k", std::make_shared<DataTypeInt32>()), 
date};
+}
+
+TEST(IcebergV2ReaderTest, TopNPrunesRowsBeforeUnneededEvolutionConversion) {
+    const auto dir = std::filesystem::temp_directory_path() / 
"topn_evolution_projection";
+    std::filesystem::create_directories(dir);
+    Defer cleanup([&] { std::filesystem::remove_all(dir); });
+    for (const bool ascending : {false, true}) {
+        for (const bool nested : {false, true}) {
+            const auto path = (dir / (std::string(ascending ? "asc" : "desc") +
+                                      (nested ? "_nested.parquet" : 
".parquet")))
+                                      .string();
+            // RG0 cannot beat any later group on k. Its DATE -> TIMESTAMP_NS 
conversion is not
+            // needed for TopN, while the same conversion must still fail in 
an unfiltered scan.
+            write_topn_evolution_file(path, ascending, nested);
+            for (const bool promote : {false, true}) {
+                for (const bool strict : {false, true}) {
+                    SCOPED_TRACE(testing::Message()
+                                 << "ascending=" << ascending << " nested=" << 
nested
+                                 << " promote=" << promote << " strict=" << 
strict);
+                    TQueryOptions options;
+                    options.__set_enable_strict_cast(strict);
+                    options.__set_batch_size(4096);
+                    RuntimeState state {options, TQueryGlobals()};
+                    auto filter = make_evolution_topn_filter(&state, 
ascending);
+                    const auto columns = topn_evolution_columns(promote, 
nested);
+                    auto scan_params = make_local_parquet_scan_params();
+                    RuntimeProfile profile("topn_evolution");
+                    iceberg::IcebergTableReader reader;
+                    ASSERT_TRUE(reader.init({.projected_columns = columns,
+                                             .conjuncts = {filter.conjunct},
+                                             .format = FileFormat::PARQUET,
+                                             .scan_params = &scan_params,
+                                             .io_ctx = nullptr,
+                                             .runtime_state = &state,
+                                             .scanner_profile = &profile})
+                                        .ok());
+                    
ASSERT_TRUE(reader.prepare_split(build_split_options(path)).ok());
+                    bool eos = false;
+                    size_t rows = 0;
+                    Status status;
+                    while (!eos) {
+                        Block block = build_table_block(columns);
+                        status = reader.get_block(&block, &eos);
+                        if (!status.ok()) {
+                            break;
+                        }
+                        rows += block.rows();
+                        if (block.rows() > 0) {
+                            const auto& values = assert_cast<const 
ColumnNullable&>(
+                                                         
*block.get_by_position(0).column)
+                                                         .get_nested_column();
+                            EXPECT_EQ(assert_cast<const 
ColumnInt32&>(values).get_element(0),
+                                      ascending ? 0 : 100);
+                            const IColumn* dates = 
block.get_by_position(1).column.get();
+                            if (nested) {
+                                dates = &assert_cast<const ColumnStruct&>(
+                                                 
expect_not_null_table_column(block, 1))
+                                                 .get_column(0);
+                            }
+                            const auto type =
+                                    promote ? 
DataTypePtr(std::make_shared<DataTypeTimeStampNs>())
+                                            : 
DataTypePtr(std::make_shared<DataTypeDateV2>());
+                            const auto& materialized =
+                                    
expect_not_null_nullable_nested_column(*dates);
+                            for (size_t row = 0; row < block.rows(); ++row) {
+                                EXPECT_EQ(type->to_string(materialized, row),
+                                          promote ? "2024-01-02 
00:00:00.000000000" : "2024-01-02");
+                            }
+                            
ASSERT_TRUE(filter.query_context->get_runtime_predicate(10)
+                                                
.update(Field::create_field<TYPE_INT>(
+                                                        ascending ? 0 : 100))
+                                                .ok());
+                        }
+                    }
+                    EXPECT_TRUE(status.ok()) << status;
+                    EXPECT_EQ(rows, 3 * 4096);
+                    ASSERT_TRUE(reader.close().ok());
+                    filter.conjunct->close();
+                }
+            }
+        }
+    }
+}
+
+TEST(IcebergV2ReaderTest, 
TopNPruningSkipsNativeAndHiddenDeleteConversionsOnlyForExcludedRows) {
+    const auto dir = std::filesystem::temp_directory_path() / 
"topn_conversion_pruning";
+    std::filesystem::create_directories(dir);
+    Defer cleanup([&] { std::filesystem::remove_all(dir); });
+    for (const bool hidden_delete : {false, true}) {
+        for (const bool ascending : {false, true}) {
+            const auto suffix = std::string(hidden_delete ? "delete_" : 
"native_") +
+                                (ascending ? "asc" : "desc");
+            const auto path = (dir / (suffix + ".parquet")).string();
+            // DATE's INT32 domain includes year 10000, which Doris DATE 
cannot materialize.
+            // Year 1000 is valid as DATE but overflows the hidden equality 
key's TIMESTAMP_NS.
+            write_topn_evolution_file(path, ascending, false, hidden_delete ? 
-354285 : 2932897);
+            const auto delete_path = (dir / (suffix + 
"_delete.parquet")).string();
+            if (hidden_delete) {
+                const auto type = arrow::timestamp(arrow::TimeUnit::NANO);
+                arrow::TimestampBuilder builder(type, 
arrow::default_memory_pool());
+                ASSERT_TRUE(builder.Append(1'704'067'200'000'000'000L).ok());
+                const auto field = arrow::field(
+                        "ts", type, true, 
arrow::key_value_metadata({"PARQUET:field_id"}, {"2"}));
+                const auto table =
+                        arrow::Table::Make(arrow::schema({field}), 
{builder.Finish().ValueOrDie()});
+                auto out = 
arrow::io::FileOutputStream::Open(delete_path).ValueOrDie();
+                ASSERT_TRUE(
+                        ::parquet::arrow::WriteTable(*table, 
arrow::default_memory_pool(), out, 1)
+                                .ok());
+                ASSERT_TRUE(out->Close().ok());
+            }
+            for (const int scenario : {0, 1, 2}) {
+                SCOPED_TRACE(testing::Message()
+                             << "hidden_delete=" << hidden_delete << " 
ascending=" << ascending
+                             << " scenario=" << scenario);
+                TQueryOptions options;
+                options.__set_enable_strict_cast(true);
+                options.__set_batch_size(4096);
+                RuntimeState state {options, TQueryGlobals()};
+                // No filter and a reversed TopN both need RG0; only scenario 
1 excludes it.
+                auto filter =
+                        make_evolution_topn_filter(&state, scenario == 2 ? 
!ascending : ascending);
+                auto columns = topn_evolution_columns(false, false);
+                if (hidden_delete) {
+                    columns.resize(1);
+                }
+                auto scan_params = make_local_parquet_scan_params();
+                
scan_params.__set_iceberg_scan_semantics_version(ICEBERG_SCAN_SEMANTICS_VERSION_2);
+                RuntimeProfile profile("topn_conversion");
+                ShardedKVCache cache(1);
+                iceberg::IcebergTableReader reader;
+                ASSERT_TRUE(reader.init({.projected_columns = columns,
+                                         .conjuncts = scenario == 0
+                                                              ? 
VExprContextSPtrs {}
+                                                              : 
VExprContextSPtrs {filter.conjunct},
+                                         .format = FileFormat::PARQUET,
+                                         .scan_params = &scan_params,
+                                         .io_ctx = nullptr,
+                                         .runtime_state = &state,
+                                         .scanner_profile = &profile})
+                                    .ok());
+                auto split = build_split_options(path);
+                split.cache = &cache;
+                if (hidden_delete) {
+                    
split.current_range.__set_table_format_params(make_iceberg_table_format_desc(
+                            path, 
{make_iceberg_equality_delete_file(delete_path, {2})}, 3));
+                }
+                ASSERT_TRUE(reader.prepare_split(split).ok());
+                bool eos = false;
+                size_t rows = 0;
+                Status status;
+                while (!eos) {
+                    Block block = build_table_block(columns);
+                    status = reader.get_block(&block, &eos);
+                    if (!status.ok()) {
+                        break;
+                    }
+                    rows += block.rows();
+                    if (block.rows() > 0) {
+                        expect_int32_column_values(
+                                *block.get_by_position(0).column,
+                                std::vector<int32_t>(block.rows(), ascending ? 
0 : 100));
+                        ASSERT_TRUE(
+                                filter.query_context->get_runtime_predicate(10)
+                                        
.update(Field::create_field<TYPE_INT>(ascending ? 0 : 100))
+                                        .ok());
+                    }
+                }
+                EXPECT_EQ(status.ok(), scenario == 1) << status;
+                if (scenario == 1) {
+                    EXPECT_EQ(rows, 3 * 4096);
+                } else {
+                    EXPECT_NE(status.to_string().find(
+                                      hidden_delete ? "TIMESTAMP_NS overflow"
+                                                    : "Parquet DATE value is 
out of range"),
+                              std::string::npos)
+                            << status;
+                }
+                ASSERT_TRUE(reader.close().ok());
+                filter.conjunct->close();
+            }
+        }
+    }
+}
+
 TEST(IcebergV2ReaderTest, DatePromotionToNanosecondsChecksOverflow) {
     const auto test_dir = std::filesystem::temp_directory_path() / 
"iceberg_date_promotion";
     std::filesystem::create_directories(test_dir);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/TopnFilterPushDownVisitor.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/TopnFilterPushDownVisitor.java
index bd72b8aaaef..e90306455c6 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/TopnFilterPushDownVisitor.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/processor/post/TopnFilterPushDownVisitor.java
@@ -82,6 +82,8 @@ public class TopnFilterPushDownVisitor extends 
PlanVisitor<Boolean, PushDownCont
 
     @Override
     public Boolean visit(Plan plan, PushDownContext ctx) {
+        // A bound on a grouping key can exclude whole groups. Stopping at 
unlisted operators
+        // here would disable existing scan filters below aggregates and their 
join inputs.
         boolean pushed = false;
         for (Plan child : plan.children()) {
             pushed |= child.accept(this, ctx);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/TopNRuntimeFilterTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/TopNRuntimeFilterTest.java
index 07fbaf84219..63654f74851 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/TopNRuntimeFilterTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/postprocess/TopNRuntimeFilterTest.java
@@ -37,6 +37,7 @@ import 
org.apache.doris.nereids.trees.plans.physical.TopnFilter;
 import org.apache.doris.nereids.util.MemoPatternMatchSupported;
 import org.apache.doris.nereids.util.PlanChecker;
 import org.apache.doris.planner.ScanNode;
+import org.apache.doris.thrift.TExplainLevel;
 
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -44,13 +45,20 @@ import org.mockito.Mockito;
 
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 public class TopNRuntimeFilterTest extends SSBTestBase implements 
MemoPatternMatchSupported {
     @Override
     public void runBeforeAll() throws Exception {
         super.runBeforeAll();
+        createTable("CREATE TABLE topn_safety (id INT, k INT, d DATE, ts 
BIGINT) DUPLICATE KEY(id) "
+                + "DISTRIBUTED BY HASH(id) BUCKETS 1 
PROPERTIES('replication_num'='1')");
         createTable("CREATE TABLE uuid_topn (id INT, u UUID) DUPLICATE KEY(id) 
"
                 + "DISTRIBUTED BY HASH(id) BUCKETS 1 
PROPERTIES('replication_num'='1')");
+        createTable("CREATE TABLE topn_nested_fields (id INT, "
+                + "ts STRUCT<a: TIMESTAMPTZ(0), b: TIMESTAMPTZ(0)>, "
+                + "si STRUCT<a: INT, b: INT>, sd STRUCT<a: DATE, b: DATE>) 
DUPLICATE KEY(id) "
+                + "DISTRIBUTED BY HASH(id) BUCKETS 1 
PROPERTIES('replication_num'='1')");
     }
 
     @Test
@@ -68,6 +76,99 @@ public class TopNRuntimeFilterTest extends SSBTestBase 
implements MemoPatternMat
         
Assertions.assertTrue(checker.getCascadesContext().getTopnFilterContext().isTopnFilterSource(localTopN));
     }
 
+    @Test
+    public void testTopNFilterDoesNotRequireTotalSecondaryExpressions() {
+        double previousRatio = 
connectContext.getSessionVariable().topnFilterRatio;
+        connectContext.getSessionVariable().topnFilterRatio = 1000000;
+        try {
+            for (String expression : List.of("weeks_add(d, 1)", 
"from_microsecond(ts)",
+                    "cast(ts AS TINYINT)", "assert_true(k < 3, 'out of 
range')")) {
+                // A row excluded by the leading key need not evaluate its 
secondary expression.
+                PlanChecker checker = PlanChecker.from(connectContext)
+                        .analyze("SELECT k, " + expression + " AS v FROM 
topn_safety "
+                                + "ORDER BY k, v LIMIT 3")
+                        .rewrite().implement();
+                PhysicalPlan plan = new 
PlanPostProcessors(checker.getCascadesContext())
+                        .process(checker.getPhysicalPlan());
+                
Assertions.assertFalse(checker.getCascadesContext().getTopnFilterContext()
+                        .getTopnFilters().isEmpty(), plan.treeString());
+            }
+        } finally {
+            connectContext.getSessionVariable().topnFilterRatio = 
previousRatio;
+        }
+    }
+
+    @Test
+    public void testTopNFilterCrossesGroupedKeyAggregation() {
+        double previousRatio = 
connectContext.getSessionVariable().topnFilterRatio;
+        boolean previousPushTopn = 
connectContext.getSessionVariable().pushTopnToAgg;
+        boolean previousJoinReorder = 
connectContext.getSessionVariable().isDisableJoinReorder();
+        String previousDisabledRules = String.join(",",
+                
connectContext.getSessionVariable().getDisableNereidsRuleNames());
+        connectContext.getSessionVariable().topnFilterRatio = 1000000;
+        connectContext.getSessionVariable().pushTopnToAgg = false;
+        connectContext.getSessionVariable().setDisableJoinReorder(true);
+        // Empty unit-test tables must retain scans to exercise the final 
translated plan.
+        
connectContext.getSessionVariable().setDisableNereidsRules(previousDisabledRules
 + ",PRUNE_EMPTY_PARTITION");
+        try {
+            for (String sql : List.of(
+                    "SELECT k, count(1) FROM topn_safety GROUP BY k ORDER BY k 
LIMIT 1",
+                    "SELECT t.k, t.total FROM (SELECT k, count(1) AS total 
FROM topn_safety "
+                            + "GROUP BY k HAVING total > 10) t JOIN customer c 
ON t.k = c.c_custkey "
+                            + "ORDER BY t.k LIMIT 1",
+                    "SELECT k, sum(ts) FROM topn_safety GROUP BY GROUPING 
SETS((k)) ORDER BY k LIMIT 2")) {
+                // Exercise the runtime filter across aggregation, 
independently of TopN-to-agg pushdown.
+                PlanChecker.from(connectContext).checkPlannerResult(sql, 
planner -> {
+                    String explain = planner.getFragments().stream()
+                            .map(fragment -> 
fragment.getExplainString(TExplainLevel.NORMAL))
+                            .collect(Collectors.joining("\n"));
+                    Assertions.assertTrue(explain.contains("TOPN OPT:"), 
explain);
+                });
+            }
+        } finally {
+            connectContext.getSessionVariable().topnFilterRatio = 
previousRatio;
+            connectContext.getSessionVariable().pushTopnToAgg = 
previousPushTopn;
+            
connectContext.getSessionVariable().setDisableJoinReorder(previousJoinReorder);
+            
connectContext.getSessionVariable().setDisableNereidsRules(previousDisabledRules);
+        }
+    }
+
+    @Test
+    public void testTopNFilterSupportsNestedOrderKeyWithSiblingPredicate() {
+        double previousRatio = 
connectContext.getSessionVariable().topnFilterRatio;
+        boolean previousPrune = 
connectContext.getSessionVariable().enablePruneNestedColumns;
+        String previousDisabledRules = String.join(",",
+                
connectContext.getSessionVariable().getDisableNereidsRuleNames());
+        connectContext.getSessionVariable().topnFilterRatio = 1000000;
+        connectContext.getSessionVariable().enablePruneNestedColumns = true;
+        // Empty unit-test tables must retain scans to exercise nested 
access-path translation.
+        
connectContext.getSessionVariable().setDisableNereidsRules(previousDisabledRules
 + ",PRUNE_EMPTY_PARTITION");
+        try {
+            for (String column : List.of("ts", "si", "sd")) {
+                for (String direction : List.of("ASC", "DESC")) {
+                    String sql = "SELECT struct_element(" + column + ", 'a') 
FROM topn_nested_fields "
+                            + "WHERE struct_element(" + column + ", 'b') IS 
NOT NULL "
+                            + "ORDER BY 1 " + direction + " NULLS LAST LIMIT 
1";
+                    PlanChecker.from(connectContext).checkPlannerResult(sql, 
planner -> {
+                        String explain = planner.getFragments().stream()
+                                .map(fragment -> 
fragment.getExplainString(TExplainLevel.NORMAL))
+                                .collect(Collectors.joining("\n"));
+                        // TopN must retain its order field when the predicate 
reads only a sibling's nulls.
+                        Assertions.assertTrue(explain.contains("TOPN OPT:"), 
explain);
+                        Assertions.assertTrue(explain.contains("all access 
paths: ["
+                                + column + ".a, " + column + ".b.NULL]"), 
explain);
+                        Assertions.assertTrue(explain.contains("predicate 
access paths: ["
+                                + column + ".b.NULL]"), explain);
+                    });
+                }
+            }
+        } finally {
+            connectContext.getSessionVariable().topnFilterRatio = 
previousRatio;
+            connectContext.getSessionVariable().enablePruneNestedColumns = 
previousPrune;
+            
connectContext.getSessionVariable().setDisableNereidsRules(previousDisabledRules);
+        }
+    }
+
     @Test
     public void testUuidTopNFilterPreservesOrderingAndNullPlacement() {
         double previousRatio = 
connectContext.getSessionVariable().topnFilterRatio;


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

Reply via email to