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 1a141d30e91 [fix](zonemap) Do not treat a legacy float or double zone 
map as NaN-free (#67779)
1a141d30e91 is described below

commit 1a141d30e9104cf18895263b52c44c2fb43cd92c
Author: YangJie <[email protected]>
AuthorDate: Wed Sep 16 22:28:08 2026 -0400

    [fix](zonemap) Do not treat a legacy float or double zone map as NaN-free 
(#67779)
    
    ### What problem does this PR solve?
    
    Issue Number: close #67773
    
    Problem Summary:
    
    `ZoneMapPB` fields 6 to 8 (`has_positive_inf`, `has_negative_inf`,
    `has_nan`) were all added in the same change that made float and double
    zone maps NaN-aware. `ZoneMap::from_proto` reads `zone_map.has_nan()`
    without consulting `has_has_nan()`, so a zone map serialized before
    those fields existed and one written by the current code for a genuinely
    NaN-free zone deserialize to the same in-memory state.
    
    That matters because the two are not the same. Float and double zone
    maps already existed before the flags did, and their bounds were
    computed by a comparison that never selects a NaN: a page holding `{1.0,
    NaN, 2.0}` stores `[1.0, 2.0]` and has nowhere to record the NaN. Doris
    orders NaN above every other value, so `WHERE d > 3.0` prunes that page
    even though the NaN row satisfies the predicate. The query loses a row
    and reports no error.
    
    The path is reachable on upgrade rather than only in theory. The 3.1
    line has no `has_nan` field, the 4.x readers have the unguarded read,
    and an upgrade replaces binaries while leaving existing data files in
    place, so a 4.x BE can read pre-4.0 rowsets immediately. The
    reversed-bounds guard added in #67431 does not cover this: it catches
    legacy pages whose bounds never moved, such as NaN-only pages, while a
    mixed finite and NaN page has ordinary ordered bounds.
    
    `from_proto` now treats an absent field 8 on a float or double column as
    "the NaN state is unknown" and marks the zone map `pass_all`, which is
    the same degradation the function already applies to a bound that fails
    to parse and to reversed bounds. Field presence is a better signal than
    a segment version gate: the writer calls `set_has_nan(false)` explicitly
    for a NaN-free zone, so presence separates current known-false metadata
    from legacy unknown metadata. Keeping this in the deserialization layer
    means segment pruning, page pruning, predicate elimination and
    expression zone map evaluation all inherit the conservative behaviour; a
    check in one consumer would leave the others exposed. `has_null` and
    `has_not_null` are untouched. What that buys depends on the path:
    `eval_null_zonemap` reads only those two flags and never consults
    `pass_all`, so IS NULL and IS NOT NULL keep pruning on the expression
    zone map path, while the ColumnPredicate path in
    `ColumnReader::_get_filtered_pages` short-circuits on `pass_all` and
    loses its IS NULL pruning for these zone maps. Pushed-down min/max
    aggregation also stops for them, because
    `segment_zone_maps_can_answer_agg` marks a `pass_all` segment unusable,
    so `SELECT max(d)` falls back to a scan.
    
    The guard only fires for a zone map that has a non-null value. An
    all-null zone never received one, so no NaN can hide in it, and leaving
    it usable keeps the three ColumnPredicate checks that open with
    `has_not_null`: the null predicates, each comparison predicate's early
    return, and the in-list one's.
    
    The cost is that range pruning stops for float and double columns in
    rowsets written before the flags existed, until compaction rewrites
    them. A finer version would keep pruning for the operators a hidden NaN
    cannot satisfy (`<`, `<=`) and disable it only for the ones it can (`>`,
    `>=`, `= NaN`, `!=` against a non-NaN literal), which is the distinction
    the Parquet readers already make with their own unknown-NaN-count flag.
    That needs a new state carried into both the expression zone map path
    and the older column predicate path, so it is a follow-up rather than
    part of a correctness fix that wants backporting.
    
    ### Release note
    
    Fixed a correctness bug where a query could silently drop rows
    containing NaN when reading float or double columns from rowsets written
    before NaN-aware zone maps.
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [ ] Regression test
        - [X] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason <!-- Add your reason?  -->
    
    `LegacyFloatZoneMapWithoutHasNanDegradesToPassAll` covers FLOAT and
    DOUBLE, nullable and not: a `ZoneMapPB` with valid ordered finite bounds
    and no field 8 comes back with `pass_all` set and its null flags intact,
    the same bounds with `set_has_nan(false)` stay usable, and an INT zone
    map without the field is unaffected.
    
    Two existing tests build a DOUBLE `ZoneMapPB` without calling
    `set_has_nan`, which now reads as legacy metadata and made their control
    cases fail. Their helpers set `set_has_nan(false)`, which is what the
    writer actually emits, so each test keeps testing what it was written
    for.
    
    The test also covers the all-null case the guard deliberately excludes,
    and both the guard and that exclusion were checked by mutation: removing
    the guard, or dropping the `has_not_null()` conjunct, each makes the new
    test fail.
    
    A reader-level fixture driving page and segment pruning would be
    stronger than a `from_proto` unit test, but the honest version of it
    needs a segment artifact produced by a pre-flag build, which is not
    something this PR can generate.
    
    - Behavior changed:
        - [ ] No.
        - [X] Yes.
    
    Float and double columns in rowsets written before the NaN flags existed
    no longer participate in range pruning. Rowsets written by current code
    are unaffected, since their zone maps carry the flag.
    
    This is a candidate for backport to the maintained 4.0 and 4.1 lines,
    which are the readers that can encounter pre-4.0 rowsets.
    
    - Does this need documentation?
        - [X] No.
---
 be/src/storage/index/zone_map/zone_map_index.cpp | 14 +++++
 be/test/storage/segment/zone_map_index_test.cpp  | 80 +++++++++++++++++++++++-
 2 files changed, 92 insertions(+), 2 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 75587761f37..401a077d130 100644
--- a/be/src/storage/index/zone_map/zone_map_index.cpp
+++ b/be/src/storage/index/zone_map/zone_map_index.cpp
@@ -78,6 +78,20 @@ Status ZoneMap::from_proto(const ZoneMapPB& zone_map, const 
DataTypePtr& data_ty
     };
 
     auto field_type = data_type->get_storage_field_type();
+
+    // has_nan arrived with NaN-aware float/double zone maps, so its absence 
means the writer could
+    // not report NaN and the bounds came from a comparison that never selects 
one: a hidden NaN
+    // cannot be ruled out. Doris orders NaN above every other value, so `x > 
c` can be true for a
+    // row that these bounds say cannot exist. Treat such a zone map as 
covering everything instead
+    // of as NaN-free. A zone with no non-null value never received one, so it 
has no NaN to hide;
+    // leaving it usable keeps the three ColumnPredicate checks that start 
from has_not_null: the
+    // null predicates, each comparison predicate's early return, and the 
in-list one's.
+    if ((field_type == FieldType::OLAP_FIELD_TYPE_FLOAT ||
+         field_type == FieldType::OLAP_FIELD_TYPE_DOUBLE) &&
+        zone_map.has_not_null() && !zone_map.has_has_nan()) {
+        zone_map_info.pass_all = true;
+    }
+
     // min value and max value are valid if has_not_null is true
     if (zone_map.has_not_null()) {
         if (!zone_map_info.pass_all) {
diff --git a/be/test/storage/segment/zone_map_index_test.cpp 
b/be/test/storage/segment/zone_map_index_test.cpp
index fb12dacf04b..85e49a1acbf 100644
--- a/be/test/storage/segment/zone_map_index_test.cpp
+++ b/be/test/storage/segment/zone_map_index_test.cpp
@@ -1014,6 +1014,74 @@ TEST_F(ColumnZoneMapTest, DoubleFiniteExtremesRoundTrip) 
{
     EXPECT_EQ(pzm.max_value.get<TYPE_DOUBLE>(), 
std::numeric_limits<double>::max());
 }
 
+TEST_F(ColumnZoneMapTest, LegacyFloatZoneMapWithoutHasNanDegradesToPassAll) {
+    // A float or double zone map written before has_nan existed says nothing 
about NaN, and its
+    // bounds were produced by a comparison that never picks a NaN. Since 
Doris sorts NaN above
+    // every other value, trusting such bounds drops NaN rows from `>` and 
`>=`.
+    auto legacy_pb = [](double min_value, double max_value) {
+        ZoneMapPB pb;
+        pb.set_min(std::to_string(min_value));
+        pb.set_max(std::to_string(max_value));
+        pb.set_has_null(false);
+        pb.set_has_not_null(true);
+        pb.set_pass_all(false);
+        // Deliberately no set_has_nan / set_has_positive_inf / 
set_has_negative_inf.
+        return pb;
+    };
+
+    for (const auto primitive_type : {TYPE_FLOAT, TYPE_DOUBLE}) {
+        for (bool nullable : {false, true}) {
+            auto data_type = 
DataTypeFactory::instance().create_data_type(primitive_type, nullable);
+            ZoneMap zm;
+            ASSERT_TRUE(ZoneMap::from_proto(legacy_pb(1.0, 2.0), data_type, 
zm).ok());
+            EXPECT_TRUE(zm.pass_all) << "type=" << primitive_type << ", 
nullable=" << nullable;
+            // The null flags survive, and so does the pruning that reads only 
them:
+            // eval_null_zonemap never consults pass_all. The ColumnPredicate 
path in
+            // ColumnReader::_get_filtered_pages does return early on 
pass_all, so it loses its
+            // IS NULL pruning for this zone map.
+            EXPECT_TRUE(zm.has_not_null);
+            EXPECT_FALSE(zm.has_null);
+
+            // The same bounds from a writer that does report the flag stay 
usable.
+            auto pb = legacy_pb(1.0, 2.0);
+            pb.set_has_nan(false);
+            ZoneMap current;
+            ASSERT_TRUE(ZoneMap::from_proto(pb, data_type, current).ok());
+            EXPECT_FALSE(current.pass_all)
+                    << "type=" << primitive_type << ", nullable=" << nullable;
+            if (primitive_type == TYPE_DOUBLE) {
+                EXPECT_EQ(1.0, current.min_value.get<TYPE_DOUBLE>());
+                EXPECT_EQ(2.0, current.max_value.get<TYPE_DOUBLE>());
+            } else {
+                EXPECT_EQ(1.0F, current.min_value.get<TYPE_FLOAT>());
+                EXPECT_EQ(2.0F, current.max_value.get<TYPE_FLOAT>());
+            }
+
+            // A legacy zone with no non-null value never received one, so no 
NaN can hide in it.
+            // The bound text is irrelevant here: from_proto only parses it 
when has_not_null.
+            auto all_null = legacy_pb(1.0, 2.0);
+            all_null.set_has_null(true);
+            all_null.set_has_not_null(false);
+            ZoneMap all_null_zm;
+            ASSERT_TRUE(ZoneMap::from_proto(all_null, data_type, 
all_null_zm).ok());
+            EXPECT_FALSE(all_null_zm.pass_all)
+                    << "type=" << primitive_type << ", nullable=" << nullable;
+        }
+    }
+
+    // Non-floating columns never carried the flag and are unaffected.
+    auto int_type = DataTypeFactory::instance().create_data_type(TYPE_INT, 
false);
+    ZoneMapPB int_pb;
+    int_pb.set_min("1");
+    int_pb.set_max("9");
+    int_pb.set_has_null(false);
+    int_pb.set_has_not_null(true);
+    int_pb.set_pass_all(false);
+    ZoneMap int_zm;
+    ASSERT_TRUE(ZoneMap::from_proto(int_pb, int_type, int_zm).ok());
+    EXPECT_FALSE(int_zm.pass_all);
+}
+
 TEST_F(ColumnZoneMapTest, LegacyUnparsableDoubleBoundDegradesToPassAll) {
     auto make_zone_map = [](const std::string& min, const std::string& max) {
         ZoneMapPB pb;
@@ -1022,6 +1090,9 @@ TEST_F(ColumnZoneMapTest, 
LegacyUnparsableDoubleBoundDegradesToPassAll) {
         pb.set_has_null(false);
         pb.set_has_not_null(true);
         pb.set_pass_all(false);
+        // The current writer always reports this flag; leaving it out would 
mark the zone map as
+        // pre-NaN-tracking legacy metadata, which is a different test.
+        pb.set_has_nan(false);
         return pb;
     };
     // 16g renderings of ±DBL_MAX, both of which read back as ∓inf.
@@ -1162,10 +1233,15 @@ TEST_F(ColumnZoneMapTest, 
ReversedBoundsDegradeToPassAll) {
         pb.set_has_null(false);
         pb.set_has_not_null(true);
         pb.set_pass_all(false);
+        // The current writer always reports this flag; leaving it out would 
mark the zone map as
+        // pre-NaN-tracking legacy metadata, which is a different test.
+        pb.set_has_nan(false);
         return pb;
     };
-    // What a page of only NaN leaves behind before 4.0: bounds that never 
moved off the values
-    // the writer starts from, and that round-trip exactly, so only the 
reversal gives them away.
+    // add_values() starts each call from numeric_limits::max() and 
::lowest(), and a page whose
+    // only non-null values are NaN or infinity leaves them there, so the 
stored pair comes back
+    // reversed. These strings round-trip exactly, and the reversal is the 
first signal either way:
+    // is_reversed runs before the flag overrides.
     const std::string double_lowest = "-1.7976931348623157e+308";
     const std::string double_highest = "1.7976931348623157e+308";
     const std::string float_lowest = "-3.4028235e+38";


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

Reply via email to