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

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


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 031bb541b08 [branch-4.2][fix](zonemap) Do not trust a cut string bound 
to prune or to answer MIN/MAX (#67642) (#68324)
031bb541b08 is described below

commit 031bb541b084e45cfc9f8d44145fe0b779ed2e49
Author: Selectdb Robot <[email protected]>
AuthorDate: Tue Sep 22 10:26:27 2026 +0800

    [branch-4.2][fix](zonemap) Do not trust a cut string bound to prune or to 
answer MIN/MAX (#67642) (#68324)
    
    ### What problem does this PR solve?
    
    Related PR: #67642
    
    Backport #67642 to `branch-4.2`, cherry-picked from
    `b0f266a8ebc1a333f806831f5d2209f837fda5d5`.
    
    Fix string ZoneMap bounds at the 512-byte cutoff: carry when raising the
    maximum, discard an all-zero overflowed range on read, and fall back to
    reading rows for exact MIN/MAX when bounds were truncated. Preserve the
    original statistics-collection override and the old SQL variable name as
    an alias.
    
    ### Branch compatibility adaptations
    
    - `segment.cpp`: retain branch-4.2's `Schema` signature and column-ID
    iteration; use `schema.column(schema.column_id(i))` for the new
    string-type check. Add the original MIN/MAX forcing and fallback logic.
    - `SessionVariable.java`: use branch-4.2's `VariableMgr.VarAttr` with
    Chinese/English descriptions, preserving the new variable name, old
    alias, default, and Thrift mapping.
    - `PaloInternalService.thrift`: add only `force_pushdown_zonemap_minmax`
    with the original field ID `1006` and default `false`. Do not import the
    unrelated `1005` SNII option from master conflict context.
    - `vgeneric_iterators_test.cpp`: although this file auto-merged, adapt
    the added fixture to branch-4.2's `Schema`/`SchemaSPtr`,
    `SegmentWriterOptions`, `TestSegmentWriter`, and `finalize()` APIs. All
    original test cases remain.
    
    ### Validation
    
    - Verified all 12 original files are included; added/deleted lines in
    the other 9 files match the original patch.
    - No unresolved conflicts, conflict markers, or unstaged edits. Code
    whitespace check passed. The generated `.out` retains the original final
    blank separator, which default `git diff --check` reports as a blank
    line at EOF.
    - Confirmed the target Schema/writer/annotation APIs and checked that
    `TQueryOptions` field IDs are unique.
    - Regression suite and expected output retain the original content; all
    added BE test cases are retained.
    - Compilation, BE unit tests, and runtime regression tests were not run
    in this session; CI validation remains pending.
    
    ### Release note
    
    Fix incorrect string ZoneMap pruning and MIN/MAX results for truncated
    string bounds.
    
    ### Check List (For Author)
    
    - Test
    - [x] Regression tests included from #67642 (not run in this session)
    - [x] Unit tests included from #67642 with branch API adaptations (not
    run in this session)
        - [ ] Manual test
    - Behavior changed:
    - [x] Yes. Apply the ZoneMap and MIN/MAX correctness fixes from #67642.
    - Does this need documentation?
        - [x] No additional documentation changes for this backport.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
    
    Co-authored-by: Chenyang Sun <[email protected]>
---
 be/src/storage/index/zone_map/zone_map_index.cpp   |  20 ++-
 be/src/storage/segment/column_reader.cpp           |   3 -
 be/src/storage/segment/segment.cpp                 |  45 +++--
 be/test/exec/scan/vgeneric_iterators_test.cpp      | 200 +++++++++++++++++++++
 be/test/storage/segment/zone_map_index_test.cpp    | 129 +++++++++++++
 .../rules/implementation/AggregateStrategies.java  |  13 +-
 .../java/org/apache/doris/qe/SessionVariable.java  |  20 ++-
 .../doris/statistics/util/StatisticsUtil.java      |   2 +-
 gensrc/thrift/PaloInternalService.thrift           |   6 +
 .../explain/test_pushdown_zonemap_minmax.out       |  19 ++
 .../explain/test_pushdown_zonemap_minmax.groovy    | 102 +++++++++++
 .../suites/statistics/analyze_stats.groovy         |   7 +-
 12 files changed, 523 insertions(+), 43 deletions(-)

diff --git a/be/src/storage/index/zone_map/zone_map_index.cpp 
b/be/src/storage/index/zone_map/zone_map_index.cpp
index 2ee6f828d1e..cc5f73ee0ab 100644
--- a/be/src/storage/index/zone_map/zone_map_index.cpp
+++ b/be/src/storage/index/zone_map/zone_map_index.cpp
@@ -38,6 +38,7 @@
 #include "storage/segment/encoding_info.h"
 #include "storage/tablet/tablet_schema.h"
 #include "storage/types.h"
+#include "storage/utils.h"
 #include "util/slice.h"
 #include "util/unaligned.h"
 
@@ -100,6 +101,14 @@ Status ZoneMap::from_proto(const ZoneMapPB& zone_map, 
const DataTypePtr& data_ty
             parse_bound(zone_map.max(), zone_map_info.max_value);
         }
 
+        // A max of all 0xff carries past its first byte and ends up all zero, 
which stands above
+        // nothing. Give up the range instead of ruling out rows with it.
+        if (!zone_map_info.pass_all && is_string_type(field_type) &&
+            zone_map.max().size() == MAX_ZONE_MAP_INDEX_SIZE &&
+            zone_map.max().find_first_not_of('\0') == std::string::npos) {
+            zone_map_info.pass_all = true;
+        }
+
         // NaN and infinity only set the flags below, never min/max, so a page 
holding nothing
         // else leaves both at the values add_values() starts from: min = 
DBL_MAX and
         // max = -DBL_MAX, neither of which is a value in the page.
@@ -247,11 +256,18 @@ void 
TypedZoneMapIndexWriter<Type>::modify_index_before_flush(
     // slightly larger than any real string that shares the same 512-byte 
prefix, ensuring no false negatives —
     // the zone map will never incorrectly skip a page that contains matching 
data.
     //
-    // In UTF8 encoding, here do not appear 0xff in last byte
+    // A string column holds arbitrary bytes, so the last byte can be 0xff. 
Adding one to it wraps
+    // to 0x00 and leaves a max below the data, so carry into the byte before 
it.
     if constexpr (Type == TYPE_CHAR || Type == TYPE_VARCHAR || Type == 
TYPE_STRING) {
         auto& str = zone_map.max_value.get<Type>();
         if (str.size() == MAX_ZONE_MAP_INDEX_SIZE) {
-            str[str.size() - 1] += 1;
+            for (size_t i = str.size(); i > 0; --i) {
+                auto byte = static_cast<uint8_t>(str[i - 1]) + 1;
+                str[i - 1] = static_cast<char>(byte);
+                if (static_cast<uint8_t>(byte) != 0) {
+                    break;
+                }
+            }
         }
     }
 }
diff --git a/be/src/storage/segment/column_reader.cpp 
b/be/src/storage/segment/column_reader.cpp
index 7e2ee007f47..f16004472c0 100644
--- a/be/src/storage/segment/column_reader.cpp
+++ b/be/src/storage/segment/column_reader.cpp
@@ -450,9 +450,6 @@ Status ColumnReader::next_batch_of_zone_map(size_t* n, 
MutableColumnPtr& dst) co
     // TODO: this work to get min/max value seems should only do once
     ZoneMap zone_map;
     RETURN_IF_ERROR(ZoneMap::from_proto(*_segment_zone_map, _data_type, 
zone_map));
-    // Segment::new_iterator does not build this iterator on an invalid zone 
map, whose min/max
-    // are unset and would be reported below as if they were data.
-    DORIS_CHECK(!zone_map.pass_all);
 
     dst->reserve(*n);
     if (!zone_map.has_not_null) {
diff --git a/be/src/storage/segment/segment.cpp 
b/be/src/storage/segment/segment.cpp
index c293434abee..0913e80f364 100644
--- a/be/src/storage/segment/segment.cpp
+++ b/be/src/storage/segment/segment.cpp
@@ -148,8 +148,14 @@ Status build_segment_zonemap_context(Segment* segment, 
const Schema& schema,
     return Status::OK();
 }
 
-// The statistics iterator answers pushed-down aggregates from the segment 
zone maps alone. An
-// invalid zone map has no min/max to answer with, so the caller has to read 
the data instead.
+// Whether to force MIN/MAX onto the zone map when its bound is not a value 
the data holds now: a
+// cut string bound, or one covering rows a delete predicate removed. 
Statistics collection sets it.
+// MIN/MAX is the only aggregate this can force, because it is the only one 
that reads the bounds.
+bool pushdown_zonemap_minmax_forced(const StorageReadOptions& read_options) {
+    return read_options.push_down_agg_type_opt == TPushAggOp::MINMAX &&
+           
read_options.runtime_state->query_options().force_pushdown_zonemap_minmax;
+}
+
 Status segment_zone_maps_can_answer_agg(Segment* segment, const Schema& schema,
                                         const StorageReadOptions& 
read_options, bool* usable) {
     *usable = true;
@@ -168,10 +174,26 @@ Status segment_zone_maps_can_answer_agg(Segment* segment, 
const Schema& schema,
         }
         ZoneMap zone_map;
         RETURN_IF_ERROR(reader->get_segment_zone_map(&zone_map));
+
+        // The zone map gave up its range, so it has no min/max left to answer 
with.
         if (zone_map.pass_all) {
             *usable = false;
             return Status::OK();
         }
+
+        // Only a string bound is cut at MAX_ZONE_MAP_INDEX_SIZE, and a column 
of nothing but
+        // nulls stored no bound to look at.
+        if (!is_string_type(schema.column(schema.column_id(i))->type()) || 
!zone_map.has_not_null) {
+            continue;
+        }
+
+        // A cut bound is not a value the column holds: the min is a prefix of 
the smallest value
+        // and the max was raised past the largest one. Neither can answer 
MIN()/MAX().
+        if (zone_map.min_value.as_string_view().size() >= 
MAX_ZONE_MAP_INDEX_SIZE ||
+            zone_map.max_value.as_string_view().size() >= 
MAX_ZONE_MAP_INDEX_SIZE) {
+            *usable = false;
+            return Status::OK();
+        }
     }
     return Status::OK();
 }
@@ -463,16 +485,17 @@ Status Segment::new_iterator(SchemaSPtr schema, const 
StorageReadOptions& read_o
         RETURN_IF_ERROR(load_index(read_options.stats, &read_options.io_ctx));
     }
 
+    // COUNT and MIX report the segment row count, which a delete predicate 
makes wrong whatever
+    // the zone map bounds hold, so they keep the guard below even when the 
switch is on.
+    const auto agg = read_options.push_down_agg_type_opt;
+    const bool forced = pushdown_zonemap_minmax_forced(read_options);
     bool use_statistics_iterator =
-            
read_options.delete_condition_predicates->num_of_column_predicate() == 0 &&
-            read_options.push_down_agg_type_opt != TPushAggOp::NONE &&
-            read_options.push_down_agg_type_opt != TPushAggOp::COUNT_ON_INDEX;
-    // COUNT only fills defaults, every other pushed-down aggregate reads 
min/max out of the
-    // segment zone maps.
-    if (use_statistics_iterator && read_options.push_down_agg_type_opt != 
TPushAggOp::COUNT) {
-        bool usable = false;
-        RETURN_IF_ERROR(segment_zone_maps_can_answer_agg(this, *schema, 
read_options, &usable));
-        use_statistics_iterator = usable;
+            agg != TPushAggOp::NONE && agg != TPushAggOp::COUNT_ON_INDEX &&
+            (forced || 
read_options.delete_condition_predicates->num_of_column_predicate() == 0);
+    // COUNT only fills defaults, every other aggregate reads min/max out of 
the zone maps.
+    if (use_statistics_iterator && !forced && agg != TPushAggOp::COUNT) {
+        RETURN_IF_ERROR(segment_zone_maps_can_answer_agg(this, *schema, 
read_options,
+                                                         
&use_statistics_iterator));
     }
     if (use_statistics_iterator) {
         iter->reset(new_vstatistics_iterator(this->shared_from_this(), 
*schema));
diff --git a/be/test/exec/scan/vgeneric_iterators_test.cpp 
b/be/test/exec/scan/vgeneric_iterators_test.cpp
index cd43d149db4..7ee626167b6 100644
--- a/be/test/exec/scan/vgeneric_iterators_test.cpp
+++ b/be/test/exec/scan/vgeneric_iterators_test.cpp
@@ -32,7 +32,12 @@
 #include "gtest/gtest_pred_impl.h"
 #include "io/fs/file_writer.h"
 #include "io/fs/local_file_system.h"
+#include "runtime/runtime_state.h"
 #include "storage/olap_common.h"
+#include "storage/olap_define.h"
+#include "storage/olap_tuple.h"
+#include "storage/predicate/block_column_predicate.h"
+#include "storage/predicate/null_predicate.h"
 #include "storage/row_cursor.h"
 #include "storage/schema.h"
 #include "storage/segment/column_reader.h"
@@ -186,6 +191,201 @@ TEST(VGenericIteratorsTest, 
StatisticsIteratorPreservesNullForNullableChar) {
     ASSERT_TRUE(fs->delete_directory(test_dir).ok());
 }
 
+// A string zone map bound is cut to 512 bytes, and a cut bound is not a value 
the column holds:
+// the min is a prefix of the smallest value and the max was raised past the 
largest one. FE pushes
+// MIN/MAX down for every string column, so the segment is the one that has to 
notice and hand the
+// query back to a normal read.
+class StatisticsIteratorStringBoundsTest : public testing::Test {
+protected:
+    static constexpr auto kTestDir = "./ut_dir/statistics_string_bounds_test";
+
+    void SetUp() override {
+        _fs = io::global_local_filesystem();
+        ASSERT_TRUE(_fs->delete_directory(kTestDir).ok());
+        ASSERT_TRUE(_fs->create_directory(kTestDir).ok());
+    }
+    void TearDown() override { 
EXPECT_TRUE(_fs->delete_directory(kTestDir).ok()); }
+
+    static TabletSchemaSPtr make_schema() {
+        auto tablet_schema = std::make_shared<TabletSchema>();
+        TabletColumn key;
+        key.set_name("c1");
+        key.set_unique_id(0);
+        key.set_type(FieldType::OLAP_FIELD_TYPE_INT);
+        key.set_length(4);
+        key.set_index_length(4);
+        key.set_is_key(true);
+        key.set_is_nullable(false);
+        tablet_schema->append_column(key);
+
+        TabletColumn value;
+        value.set_name("c2");
+        value.set_unique_id(1);
+        value.set_type(FieldType::OLAP_FIELD_TYPE_VARCHAR);
+        value.set_length(65535);
+        value.set_is_key(false);
+        value.set_is_nullable(false);
+        
value.set_aggregation_method(FieldAggregationMethod::OLAP_FIELD_AGGREGATION_NONE);
+        tablet_schema->append_column(value);
+        tablet_schema->set_storage_page_size(4096);
+        return tablet_schema;
+    }
+
+    // Writes one segment holding `values` in the VARCHAR column and returns 
the iterator that the
+    // pushed-down `agg` would run on. `accept_cut_bound` is what statistics 
collection sets:
+    // it takes an inexact min/max as an approximation instead of reading the 
data.
+    // `with_delete` adds a delete predicate, which leaves the zone map 
covering removed rows.
+    std::unique_ptr<RowwiseIterator> pushdown_iterator_for(
+            const std::string& name, const std::vector<std::string>& values,
+            bool accept_cut_bound = false, bool with_delete = false,
+            TPushAggOp::type agg = TPushAggOp::MINMAX) {
+        auto tablet_schema = make_schema();
+        const std::string segment_path = std::string(kTestDir) + "/" + name + 
".dat";
+
+        io::FileWriterPtr file_writer;
+        EXPECT_TRUE(_fs->create_file(segment_path, &file_writer).ok());
+        SegmentWriterOptions writer_options;
+        writer_options.num_rows_per_block = 1024;
+        TestSegmentWriter writer(file_writer.get(), 0, tablet_schema, nullptr, 
nullptr, writer_options,
+                                 nullptr);
+        EXPECT_TRUE(writer.init().ok());
+
+        RowCursor row;
+        OlapTuple tuple;
+        for (size_t i = 0; i < tablet_schema->num_columns(); ++i) {
+            tuple.add_null();
+        }
+        EXPECT_EQ(Status::OK(), row.init(tablet_schema, tuple));
+        for (size_t i = 0; i < values.size(); ++i) {
+            row.mutable_field(0) = 
Field::create_field<TYPE_INT>(static_cast<int32_t>(i));
+            row.mutable_field(1) = 
Field::create_field<TYPE_STRING>(String(values[i]));
+            EXPECT_TRUE(writer.append_row(row).ok());
+        }
+        uint64_t file_size = 0;
+        uint64_t index_size = 0;
+        EXPECT_TRUE(writer.finalize(&file_size, &index_size).ok());
+        EXPECT_TRUE(file_writer->close().ok());
+
+        std::shared_ptr<segment_v2::Segment> segment;
+        EXPECT_TRUE(segment_v2::Segment::open(_fs, segment_path, 100, 0, 
RowsetId {.version = 1},
+                                              tablet_schema, 
io::FileReaderOptions {}, &segment)
+                            .ok());
+
+        std::vector<ColumnId> column_ids {0, 1};
+        // VStatisticsIterator keeps a reference to the schema, so it has to 
outlive the iterator.
+        auto schema = std::make_shared<Schema>(tablet_schema->columns(), 
column_ids);
+        StorageReadOptions read_options;
+        read_options.push_down_agg_type_opt = agg;
+        read_options.stats = &_stats;
+        read_options.tablet_schema = tablet_schema;
+
+        if (with_delete) {
+            auto del_pred = NullPredicate::create_shared(0, "c1", true, 
PrimitiveType::TYPE_INT);
+            read_options.delete_condition_predicates->add_column_predicate(
+                    SingleColumnBlockPredicate::create_unique(del_pred));
+        }
+
+        auto state = std::make_unique<RuntimeState>();
+        TQueryOptions query_options;
+        query_options.__set_force_pushdown_zonemap_minmax(accept_cut_bound);
+        state->set_query_options(query_options);
+        read_options.runtime_state = state.get();
+        // The iterator keeps a copy of read_options, so the state has to 
outlive it.
+        _states.push_back(std::move(state));
+        _schemas.push_back(schema);
+
+        std::unique_ptr<RowwiseIterator> iter;
+        EXPECT_TRUE(segment->new_iterator(schema, read_options, &iter).ok());
+        return iter;
+    }
+
+    std::shared_ptr<io::FileSystem> _fs;
+    OlapReaderStatistics _stats;
+    std::vector<std::unique_ptr<RuntimeState>> _states;
+    std::vector<SchemaSPtr> _schemas;
+};
+
+TEST_F(StatisticsIteratorStringBoundsTest, ShortBoundsAnswerFromTheZoneMap) {
+    // Every value fits well inside the 512-byte bound, so the stored min/max 
are the real ones.
+    auto iter = pushdown_iterator_for("short", {"aaa", "bbb", "ccc"});
+    EXPECT_NE(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr)
+            << "exact bounds can answer MIN/MAX without reading the data";
+}
+
+TEST_F(StatisticsIteratorStringBoundsTest, CutBoundsFallBackToReadingTheData) {
+    // The longest value runs past the 512-byte cut, so the stored max is a 
raised prefix and not a
+    // value in the column. Answering MIN/MAX from it would return a string 
the table never held.
+    auto iter = pushdown_iterator_for("cut", {"aaa", "bbb", std::string(600, 
'c')});
+    EXPECT_EQ(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr)
+            << "a cut bound is not a value from the data, so the query has to 
read the rows";
+}
+
+// A VARCHAR(512) column full to its declared length was cut too, and FE used 
to push MIN/MAX down
+// for it because the length is not over 512.
+TEST_F(StatisticsIteratorStringBoundsTest, BoundsCutExactlyAtTheLimitFallBack) 
{
+    auto iter = pushdown_iterator_for("exact", {"aaa", 
std::string(MAX_ZONE_MAP_INDEX_SIZE, 'z')});
+    EXPECT_EQ(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr);
+}
+
+// Statistics collection only needs an approximation, and reading the data 
instead would scan the
+// whole table. It keeps the statistics iterator even when the stored bounds 
were cut.
+TEST_F(StatisticsIteratorStringBoundsTest, 
CutBoundsAnswerWhenTheCallerTakesAnApproximation) {
+    auto iter = pushdown_iterator_for("cut_approx", {"aaa", "bbb", 
std::string(600, 'c')},
+                                      /*accept_cut_bound=*/true);
+    EXPECT_NE(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr)
+            << "statistics collection reads the cut bound rather than scanning 
the rows";
+}
+
+// A max raised from 0xff wraps to 0x00, so the read side turns pass_all on 
for that zone. The
+// bounds were parsed before that happened, so statistics collection still 
reads them.
+TEST_F(StatisticsIteratorStringBoundsTest, 
PassAllZoneMapAnswersWhenApproximationIsAccepted) {
+    std::string wrapping(MAX_ZONE_MAP_INDEX_SIZE - 1, 'a');
+    wrapping.push_back(static_cast<char>(0xff));
+    auto iter = pushdown_iterator_for("pass_all_approx", {"aaa", wrapping},
+                                      /*accept_cut_bound=*/true);
+    EXPECT_NE(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr)
+            << "a zone map that gave up its range on read still carries the 
bounds it parsed";
+
+    Block block;
+    for (const auto& column : iter->schema().columns()) {
+        auto data_type = column->get_vec_type();
+        block.insert(ColumnWithTypeAndName(data_type->create_column(), 
data_type, column->name()));
+    }
+    EXPECT_TRUE(iter->next_batch(&block).ok()) << "reading the bounds must not 
trip an assertion";
+}
+
+// With the switch off the same zone map sends the query back to the rows.
+TEST_F(StatisticsIteratorStringBoundsTest, 
PassAllZoneMapFallsBackToReadingTheData) {
+    std::string wrapping(MAX_ZONE_MAP_INDEX_SIZE - 1, 'a');
+    wrapping.push_back(static_cast<char>(0xff));
+    auto iter = pushdown_iterator_for("pass_all_exact", {"aaa", wrapping});
+    EXPECT_EQ(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr);
+}
+
+// A delete predicate leaves the zone map covering rows that are gone, so its 
min/max may name a
+// value the table no longer holds. That is a real answer for every query but 
statistics
+// collection, which takes the approximation to avoid scanning the table.
+TEST_F(StatisticsIteratorStringBoundsTest, 
DeletePredicateFallsBackToReadingTheData) {
+    auto iter = pushdown_iterator_for("del_exact", {"aaa", "bbb"}, 
/*accept_cut_bound=*/false,
+                                      /*with_delete=*/true);
+    EXPECT_EQ(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr)
+            << "a deleted row may still sit inside the zone map bounds";
+}
+
+TEST_F(StatisticsIteratorStringBoundsTest, 
DeletePredicateAnswersWhenApproximationIsAccepted) {
+    auto iter = pushdown_iterator_for("del_approx", {"aaa", "bbb"}, 
/*accept_cut_bound=*/true,
+                                      /*with_delete=*/true);
+    EXPECT_NE(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr)
+            << "statistics collection keeps the zone map even with a delete 
predicate";
+}
+
+TEST_F(StatisticsIteratorStringBoundsTest, 
CountKeepsTheDeletePredicateGuardWhenForced) {
+    auto iter = pushdown_iterator_for("count_del", {"aaa", "bbb"}, 
/*accept_cut_bound=*/true,
+                                      /*with_delete=*/true, TPushAggOp::COUNT);
+    EXPECT_EQ(dynamic_cast<VStatisticsIterator*>(iter.get()), nullptr)
+            << "COUNT reports the segment row count, which still counts the 
deleted rows";
+}
+
 TEST(VGenericIteratorsTest, Union) {
     auto schema = create_schema();
     auto output_schema = std::make_shared<Schema>(schema);
diff --git a/be/test/storage/segment/zone_map_index_test.cpp 
b/be/test/storage/segment/zone_map_index_test.cpp
index 36a4883f7c1..9f46a5821c3 100644
--- a/be/test/storage/segment/zone_map_index_test.cpp
+++ b/be/test/storage/segment/zone_map_index_test.cpp
@@ -1598,5 +1598,134 @@ TEST_F(ColumnZoneMapTest, EmbeddedNulKeepsStringBound) {
     test_embedded_nul_bound<TYPE_CHAR>("embedded_nul_char", 
/*bound_is_cut=*/true);
 }
 
+// The writer raises the last byte of every cut max, including one that wraps. 
Storing the bound
+// anyway keeps it available to a reader that only needs an approximation, and 
the read side is
+// where the wrap is caught.
+TEST_F(ColumnZoneMapTest, WriterRaisesEveryCutMax) {
+    auto data_type = DataTypeFactory::instance().create_data_type(TYPE_STRING, 
true, 0, 0, -1);
+    TabletColumnPtr tab_col = create_string_key(0);
+
+    struct Raised {
+        std::string max;
+        bool pass_all;
+    };
+    auto raise_max = [&](const std::string& value) {
+        std::unique_ptr<ZoneMapIndexWriter> writer;
+        EXPECT_TRUE(ZoneMapIndexWriter::create(data_type, tab_col.get(), 
writer).ok());
+        segment_v2::ZoneMap zone_map;
+        zone_map.min_value = Field::create_field<TYPE_STRING>(value);
+        zone_map.max_value = Field::create_field<TYPE_STRING>(value);
+        zone_map.has_not_null = true;
+        writer->modify_index_before_flush(zone_map);
+        return Raised {zone_map.max_value.get<TYPE_STRING>(), 
zone_map.pass_all};
+    };
+
+    // 511 'a' then 0xff: adding one to the last byte wraps it, so the carry 
goes into the byte
+    // before it and the max still stands above the value it covers.
+    std::string trailing_ff(MAX_ZONE_MAP_INDEX_SIZE - 1, 'a');
+    trailing_ff.push_back(static_cast<char>(0xff));
+    const auto carried = raise_max(trailing_ff);
+    EXPECT_FALSE(carried.pass_all);
+    EXPECT_EQ(std::string(MAX_ZONE_MAP_INDEX_SIZE - 2, 'a') + "b" + '\0', 
carried.max);
+    EXPECT_GT(carried.max, trailing_ff) << "max must stay above the value it 
covers";
+
+    // A max that is 0xff all the way down carries past its first byte, so 
every byte ends at
+    // 0x00. The read side spots that the same way it spots an old wrap.
+    const auto all_ff = raise_max(std::string(MAX_ZONE_MAP_INDEX_SIZE, 
static_cast<char>(0xff)));
+    EXPECT_FALSE(all_ff.pass_all);
+    EXPECT_EQ(std::string(MAX_ZONE_MAP_INDEX_SIZE, '\0'), all_ff.max);
+
+    // A plain max gets the plain raise.
+    const std::string plain(MAX_ZONE_MAP_INDEX_SIZE, 'x');
+    const auto raised = raise_max(plain);
+    EXPECT_FALSE(raised.pass_all);
+    EXPECT_EQ(std::string(MAX_ZONE_MAP_INDEX_SIZE - 1, 'x') + "y", raised.max);
+    EXPECT_GT(raised.max, plain) << "max must stay above the value it covers";
+
+    // The 512-byte cut is a plain byte cut, so it can land inside a character 
and leave a bound
+    // that is not UTF-8. The raise still stands above every value sharing the 
prefix, so the zone
+    // keeps its range: long CJK text must not lose pruning over a split 
character.
+    std::string cut_mid_char(MAX_ZONE_MAP_INDEX_SIZE - 2, 'a');
+    cut_mid_char.push_back(static_cast<char>(0xe4)); // first byte of a 
three-byte character
+    cut_mid_char.push_back(static_cast<char>(0xb8));
+    const auto mid_char = raise_max(cut_mid_char);
+    EXPECT_FALSE(mid_char.pass_all);
+    EXPECT_GT(mid_char.max, cut_mid_char);
+
+    // Same when the cut keeps only the first byte of that character.
+    std::string cut_after_lead(MAX_ZONE_MAP_INDEX_SIZE - 1, 'a');
+    cut_after_lead.push_back(static_cast<char>(0xe4));
+    const auto after_lead = raise_max(cut_after_lead);
+    EXPECT_FALSE(after_lead.pass_all);
+    EXPECT_GT(after_lead.max, cut_after_lead);
+
+    // A character that ends right on the cut is whole.
+    std::string cut_on_boundary(MAX_ZONE_MAP_INDEX_SIZE - 3, 'a');
+    cut_on_boundary.push_back(static_cast<char>(0xe4));
+    cut_on_boundary.push_back(static_cast<char>(0xb8));
+    cut_on_boundary.push_back(static_cast<char>(0xad));
+    const auto whole_raised = raise_max(cut_on_boundary);
+    EXPECT_FALSE(whole_raised.pass_all);
+    EXPECT_EQ(static_cast<unsigned char>(whole_raised.max.back()), 0xae);
+    EXPECT_GT(whole_raised.max, cut_on_boundary);
+}
+
+// The writer raises every cut max, so a max that came from 0xff wrapped to 
0x00 and now sits
+// below the rows it covers. The read side has to spot that and give up the 
range, or those rows
+// stay invisible. Segments written before this carry the same wrapped max.
+TEST_F(ColumnZoneMapTest, 
FromProtoGivesUpTheRangeForAMaxThatCarriedPastItsEnd) {
+    auto data_type = DataTypeFactory::instance().create_data_type(TYPE_STRING, 
true, 0, 0, -1);
+
+    auto reads_back_as_pass_all = [&](const std::string& min, const 
std::string& max) {
+        ZoneMapPB pb;
+        pb.set_min(min);
+        pb.set_max(max);
+        pb.set_has_null(false);
+        pb.set_has_not_null(true);
+        pb.set_pass_all(false);
+        ZoneMap zone_map;
+        EXPECT_TRUE(ZoneMap::from_proto(pb, data_type, zone_map).ok());
+        return zone_map.pass_all;
+    };
+
+    // A carry that stopped inside the bound left 0x00 in the last byte, but 
an earlier byte went
+    // up, so the max still stands above the rows. Only an all-zero max covers 
nothing.
+    std::string carried(MAX_ZONE_MAP_INDEX_SIZE - 2, 'a');
+    carried.push_back('b');
+    carried.push_back('\0');
+    EXPECT_FALSE(reads_back_as_pass_all("aaa", carried));
+
+    // A max raised from a plain byte keeps its range.
+    EXPECT_FALSE(
+            reads_back_as_pass_all("aaa", std::string(MAX_ZONE_MAP_INDEX_SIZE 
- 1, 'x') + "y"));
+
+    // So does one raised from a whole character.
+    std::string whole_raised(MAX_ZONE_MAP_INDEX_SIZE - 3, 'a');
+    whole_raised.push_back(static_cast<char>(0xe4));
+    whole_raised.push_back(static_cast<char>(0xb8));
+    whole_raised.push_back(static_cast<char>(0xae));
+    EXPECT_FALSE(reads_back_as_pass_all("aaa", whole_raised));
+
+    // A cut that split a character in half still raised the last byte, so the 
max stands above
+    // every value sharing the prefix. Long CJK text must not lose pruning 
over that.
+    std::string cut_raised(MAX_ZONE_MAP_INDEX_SIZE - 2, 'a');
+    cut_raised.push_back(static_cast<char>(0xe4));
+    cut_raised.push_back(static_cast<char>(0xb9));
+    EXPECT_FALSE(reads_back_as_pass_all("aaa", cut_raised));
+
+    // A max ending in 0xff was never raised into one, so it keeps its range.
+    std::string ends_with_ff(MAX_ZONE_MAP_INDEX_SIZE - 1, 'a');
+    ends_with_ff.push_back(static_cast<char>(0xff));
+    EXPECT_FALSE(reads_back_as_pass_all("aaa", ends_with_ff));
+
+    // A max of all 0xff carries through every byte and ends up all zero, 
covering nothing.
+    EXPECT_TRUE(reads_back_as_pass_all("aaa", 
std::string(MAX_ZONE_MAP_INDEX_SIZE, '\0')));
+
+    // A max shorter than the cut was never raised, so it is exact whatever 
bytes it holds.
+    std::string short_ff = "abc";
+    short_ff.push_back(static_cast<char>(0xff));
+    EXPECT_FALSE(reads_back_as_pass_all("abc", short_ff));
+}
+
 } // namespace segment_v2
 } // namespace doris
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/AggregateStrategies.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/AggregateStrategies.java
index 9d4814aea80..da0193aa0c5 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/AggregateStrategies.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/implementation/AggregateStrategies.java
@@ -738,15 +738,9 @@ public class AggregateStrategies implements 
ImplementationRuleFactory {
             if (column.isAggregated()) {
                 return canNotPush;
             }
-            // The zone map max length of CharFamily is 512, do not
-            // over the length: https://github.com/apache/doris/pull/6293
             if (mergeOp == PushDownAggOp.MIN_MAX || mergeOp == 
PushDownAggOp.MIX) {
                 PrimitiveType colType = column.getType().getPrimitiveType();
-                if (colType.isComplexType() || colType.isHllType() || 
colType.isBitmapType()
-                         || (colType == PrimitiveType.STRING && 
!enablePushDownStringMinMax())) {
-                    return canNotPush;
-                }
-                if (colType.isCharFamily() && column.getType().getLength() > 
512 && !enablePushDownStringMinMax()) {
+                if (colType.isComplexType() || colType.isHllType() || 
colType.isBitmapType()) {
                     return canNotPush;
                 }
             }
@@ -811,11 +805,6 @@ public class AggregateStrategies implements 
ImplementationRuleFactory {
         }
     }
 
-    private boolean enablePushDownStringMinMax() {
-        ConnectContext connectContext = ConnectContext.get();
-        return connectContext != null && 
connectContext.getSessionVariable().isEnablePushDownStringMinMax();
-    }
-
     private boolean enablePushDownNoGroupAgg() {
         ConnectContext connectContext = ConnectContext.get();
         return connectContext == null || 
connectContext.getSessionVariable().enablePushDownNoGroupAgg();
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java 
b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
index 1bf84014bbc..0c099760736 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/SessionVariable.java
@@ -801,7 +801,7 @@ public class SessionVariable implements Serializable, 
Writable {
 
     public static final String KEEP_CARRIAGE_RETURN = "keep_carriage_return";
 
-    public static final String ENABLE_PUSHDOWN_STRING_MINMAX = 
"enable_pushdown_string_minmax";
+    public static final String FORCE_PUSHDOWN_ZONEMAP_MINMAX = 
"force_pushdown_zonemap_minmax";
 
     public static final String ENABLE_MOR_VALUE_PREDICATE_PUSHDOWN_TABLES
             = "enable_mor_value_predicate_pushdown_tables";
@@ -2443,10 +2443,15 @@ public class SessionVariable implements Serializable, 
Writable {
         "是否启用 pushdown minmax on unique table。", "Set whether to pushdown 
minmax on unique table."})
     public boolean enablePushDownMinMaxOnUnique = false;
 
-    // Whether enable push down string type minmax to scan node.
-    @VariableMgr.VarAttr(name = ENABLE_PUSHDOWN_STRING_MINMAX, needForward = 
true, description = {
-        "是否启用 string 类型 min max 下推。", "Set whether to enable push down string 
type minmax."})
-    public boolean enablePushDownStringMinMax = false;
+    // Whether to force MIN/MAX onto the zone map when its bound is not a 
value the data holds now:
+    // a cut string bound, or one covering rows a delete predicate removed. 
The alias is the old
+    // name, from when this only governed string bounds.
+    @VariableMgr.VarAttr(name = FORCE_PUSHDOWN_ZONEMAP_MINMAX, alias = 
{"enable_pushdown_string_minmax"},
+            needForward = true, description = {
+                "当 ZoneMap 边界为截断的字符串前缀或仍覆盖已删除行时,是否强制使用 ZoneMap 执行下推的 MIN/MAX。",
+                "Set whether to force a pushed down minmax onto the zone map 
when its "
+                        + "bound is a cut string prefix, or still covers rows 
a delete predicate removed."})
+    public boolean forcePushDownZonemapMinMax = false;
 
     // Comma-separated list of MOR tables to enable value predicate pushdown.
     @VariableMgr.VarAttr(name = ENABLE_MOR_VALUE_PREDICATE_PUSHDOWN_TABLES, 
needForward = true, description = {
@@ -5314,10 +5319,6 @@ public class SessionVariable implements Serializable, 
Writable {
         this.enablePushDownMinMaxOnUnique = enablePushDownMinMaxOnUnique;
     }
 
-    public boolean isEnablePushDownStringMinMax() {
-        return enablePushDownStringMinMax;
-    }
-
     public String getEnableMorValuePredicatePushdownTables() {
         return enableMorValuePredicatePushdownTables;
     }
@@ -5819,6 +5820,7 @@ public class SessionVariable implements Serializable, 
Writable {
         tResult.setEnableInvertedIndexQuery(enableInvertedIndexQuery);
         
tResult.setEnableCommonExprPushdownForInvertedIndex(enableCommonExpPushDownForInvertedIndex);
         tResult.setEnableNoNeedReadDataOpt(enableNoNeedReadDataOpt);
+        tResult.setForcePushdownZonemapMinmax(forcePushDownZonemapMinMax);
 
         if (dryRunQuery) {
             tResult.setDryRunQuery(true);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/util/StatisticsUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/util/StatisticsUtil.java
index 0bc6ea8a699..3235f6c7fba 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/util/StatisticsUtil.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/util/StatisticsUtil.java
@@ -228,7 +228,7 @@ public class StatisticsUtil {
         sessionVariable.enableFileCache = false;
         sessionVariable.forbidUnknownColStats = false;
         sessionVariable.enablePushDownMinMaxOnUnique = true;
-        sessionVariable.enablePushDownStringMinMax = true;
+        sessionVariable.forcePushDownZonemapMinMax = true;
         sessionVariable.enableUniqueKeyPartialUpdate = false;
         sessionVariable.enableMaterializedViewRewrite = false;
         sessionVariable.enableQueryCache = false;
diff --git a/gensrc/thrift/PaloInternalService.thrift 
b/gensrc/thrift/PaloInternalService.thrift
index 9e6d34c7c39..e2ba8937684 100644
--- a/gensrc/thrift/PaloInternalService.thrift
+++ b/gensrc/thrift/PaloInternalService.thrift
@@ -519,6 +519,12 @@ struct TQueryOptions {
   1002: optional bool enable_file_scanner_v2 = false
   1003: optional bool enable_topn_lazy_mat_phase2_no_write_file_cache = false
   1004: optional i64 file_cache_query_limit_bytes = -1
+  // Whether to force a pushed-down MIN/MAX onto the zone map even when its 
bound is not a value
+  // the data holds right now: a string bound cut at 512 bytes is a prefix, 
and any bound still
+  // covers rows a delete predicate removed. Statistics collection sets it; 
every other query
+  // reads the data instead.
+  // Defaults to false because an old FE never sends this field, and BE 
checked both cases before.
+  1006: optional bool force_pushdown_zonemap_minmax = false
 }
 
 
diff --git 
a/regression-test/data/query_p0/explain/test_pushdown_zonemap_minmax.out 
b/regression-test/data/query_p0/explain/test_pushdown_zonemap_minmax.out
new file mode 100644
index 00000000000..1c9976143ee
--- /dev/null
+++ b/regression-test/data/query_p0/explain/test_pushdown_zonemap_minmax.out
@@ -0,0 +1,19 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !wide_short --
+aaa    zzz
+
+-- !wide_long --
+aaa    600     zzzz
+
+-- !512_max --
+512    dddd
+
+-- !512_eq --
+1
+
+-- !str_off --
+aaa    600     zzzz
+
+-- !str_on --
+512    zzz{
+
diff --git 
a/regression-test/suites/query_p0/explain/test_pushdown_zonemap_minmax.groovy 
b/regression-test/suites/query_p0/explain/test_pushdown_zonemap_minmax.groovy
new file mode 100644
index 00000000000..c10d180ef99
--- /dev/null
+++ 
b/regression-test/suites/query_p0/explain/test_pushdown_zonemap_minmax.groovy
@@ -0,0 +1,102 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// MIN/MAX push-down no longer looks at the declared column length. The 
storage layer decides per
+// segment: a zone map bound cut to 512 bytes is not a value the column holds, 
so those segments
+// read the rows instead.
+suite("test_pushdown_zonemap_minmax") {
+    def longValue = "z" * 600
+    def exactValue = "d" * 512
+
+    // A VARCHAR wider than 512 used to be excluded by its declared length 
alone, even when every
+    // value in it was short.
+    sql "DROP TABLE IF EXISTS test_string_minmax_wide"
+    sql """
+        CREATE TABLE test_string_minmax_wide (
+            `id` INT NOT NULL,
+            `v` VARCHAR(65533) NOT NULL
+        ) ENGINE=OLAP
+        DUPLICATE KEY(`id`)
+        DISTRIBUTED BY HASH(`id`) BUCKETS 1
+        PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+    """
+    sql """ INSERT INTO test_string_minmax_wide VALUES (1, "aaa"), (2, "zzz") 
"""
+    explain {
+        sql "select min(v), max(v) from test_string_minmax_wide"
+        contains "pushAggOp=MINMAX"
+    }
+    qt_wide_short "select min(v), max(v) from test_string_minmax_wide"
+
+    // A value past the 512-byte cut: the plan still pushes down, and the 
storage layer falls back
+    // per segment so the answer stays a value the table holds.
+    sql """ INSERT INTO test_string_minmax_wide VALUES (3, "${longValue}") """
+    explain {
+        sql "select min(v), max(v) from test_string_minmax_wide"
+        contains "pushAggOp=MINMAX"
+    }
+    qt_wide_long "select min(v), length(max(v)), right(max(v), 4) from 
test_string_minmax_wide"
+
+    // A VARCHAR filled to exactly 512 bytes is cut as well. FE pushed this 
down before too,
+    // because the declared length is not over 512, and the raised bound 
answered MAX with a value
+    // that was never inserted.
+    sql "DROP TABLE IF EXISTS test_string_minmax_512"
+    sql """
+        CREATE TABLE test_string_minmax_512 (
+            `id` INT NOT NULL,
+            `v` VARCHAR(512) NOT NULL
+        ) ENGINE=OLAP
+        DUPLICATE KEY(`id`)
+        DISTRIBUTED BY HASH(`id`) BUCKETS 1
+        PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+    """
+    sql """ INSERT INTO test_string_minmax_512 VALUES (1, "aaa"), (2, 
"${exactValue}") """
+    qt_512_max "select length(max(v)), right(max(v), 4) from 
test_string_minmax_512"
+    qt_512_eq "select count(*) from test_string_minmax_512 where v = 
'${exactValue}'"
+
+    // The switch says whether a cut bound may answer MIN/MAX. It is off by 
default, so the answer
+    // is always a value the table holds. Statistics collection turns it on 
and takes the cut bound
+    // rather than reading every row.
+    sql "DROP TABLE IF EXISTS test_string_minmax_str"
+    sql """
+        CREATE TABLE test_string_minmax_str (
+            `id` INT NOT NULL,
+            `v` STRING NOT NULL
+        ) ENGINE=OLAP
+        DUPLICATE KEY(`id`)
+        DISTRIBUTED BY HASH(`id`) BUCKETS 1
+        PROPERTIES ("replication_allocation" = "tag.location.default: 1");
+    """
+    sql """ INSERT INTO test_string_minmax_str VALUES (1, "aaa"), (2, 
"${longValue}") """
+
+    // Off by default: the plan still pushes down, and the segment whose bound 
was cut reads its
+    // rows, so both answers are values the table holds.
+    explain {
+        sql "select min(v), max(v) from test_string_minmax_str"
+        contains "pushAggOp=MINMAX"
+    }
+    qt_str_off "select min(v), length(max(v)), right(max(v), 4) from 
test_string_minmax_str"
+
+    // On: the cut bound answers straight away. It is the 512-byte prefix with 
its last byte
+    // raised, so the max ends in '{', one past the 'z' that was inserted.
+    sql "set force_pushdown_zonemap_minmax = true"
+    explain {
+        sql "select min(v), max(v) from test_string_minmax_str"
+        contains "pushAggOp=MINMAX"
+    }
+    qt_str_on "select length(max(v)), right(max(v), 4) from 
test_string_minmax_str"
+    sql "set force_pushdown_zonemap_minmax = false"
+}
diff --git a/regression-test/suites/statistics/analyze_stats.groovy 
b/regression-test/suites/statistics/analyze_stats.groovy
index 7814f2924d1..c0eaff54e2b 100644
--- a/regression-test/suites/statistics/analyze_stats.groovy
+++ b/regression-test/suites/statistics/analyze_stats.groovy
@@ -2747,11 +2747,8 @@ PARTITION `p599` VALUES IN (599)
    """
     sql """insert into string_min_max values (1,'name1'), (2, 'name2')"""
     sql """analyze table string_min_max with sync"""
-    explain {
-        sql("select min(name), max(name) from string_min_max")
-        contains "pushAggOp=NONE"
-    }
-    sql """set enable_pushdown_string_minmax = true"""
+    // Every string column is pushed down now, and the storage layer decides 
per segment whether
+    // a cut bound may answer. These bounds are short, so the zone map answers 
them.
     explain {
         sql("select min(name), max(name) from string_min_max")
         contains "pushAggOp=MINMAX"


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

Reply via email to