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
commit 5eb01bb3d8257dc7f7f23744798b41830991a417
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Sep 16 10:53:46 2026 +0800
branch-4.1: [fix](zonemap) Keep every byte of a STRING/VARCHAR zone map
bound #67949 (#67998)
Cherry-picked from #67949
Co-authored-by: Chenyang Sun <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
.../data_type_serde/data_type_string_serde.cpp | 15 ++--
be/test/storage/olap_type_test.cpp | 38 ++++++-----
be/test/storage/segment/zone_map_index_test.cpp | 79 ++++++++++++++++++++++
.../string/test_string_embedded_nul_zonemap.out | 73 ++++++++++++++++++++
.../string/test_string_embedded_nul_zonemap.groovy | 79 ++++++++++++++++++++++
5 files changed, 262 insertions(+), 22 deletions(-)
diff --git a/be/src/core/data_type_serde/data_type_string_serde.cpp
b/be/src/core/data_type_serde/data_type_string_serde.cpp
index 39eafdacf9a..bccc79e75cd 100644
--- a/be/src/core/data_type_serde/data_type_string_serde.cpp
+++ b/be/src/core/data_type_serde/data_type_string_serde.cpp
@@ -847,12 +847,15 @@ Status
DataTypeStringSerDeBase<ColumnType>::from_string(StringRef& str, IColumn&
template <typename ColumnType>
Status DataTypeStringSerDeBase<ColumnType>::from_olap_string(const
std::string& str, Field& field,
const
FormatOptions& options) const {
- // CHAR(N) writes through OlapColumnDataConvertorChar are zero-padded to
- // the declared schema length, so the serialized OLAP string carries
- // trailing '\0' bytes. strnlen() drops that padding to surface the
- // logical character content in the Field. VARCHAR / STRING never write
- // trailing '\0' through this path, so strnlen is a no-op for them.
- size_t len = strnlen(str.data(), str.size());
+ // CHAR(N) is zero-padded to the declared schema length before it is
written, so its
+ // stored bytes carry trailing '\0' and stop at the first one. The page
read path cuts
+ // CHAR values the same way (see BinaryPlainPageCharStripPreDecoder), so a
bound built
+ // like this stays comparable with the rows it describes.
+ //
+ // VARCHAR and STRING keep every byte they were given, '\0' included.
Cutting such a
+ // value at an embedded '\0' would give a bound the data never held, and a
zone map
+ // built from it prunes rows that match.
+ size_t len = _type == TYPE_CHAR ? strnlen(str.data(), str.size()) :
str.size();
field = Field::create_field<TYPE_STRING>(std::string(str.data(), len));
return Status::OK();
}
diff --git a/be/test/storage/olap_type_test.cpp
b/be/test/storage/olap_type_test.cpp
index 53b76ab5dce..4a9b3f5cb5d 100644
--- a/be/test/storage/olap_type_test.cpp
+++ b/be/test/storage/olap_type_test.cpp
@@ -1867,11 +1867,9 @@ TEST_F(OlapTypeTest, timestamptz_type) {
}
}
-// from_olap_string for string types (CHAR / VARCHAR / STRING) strnlens the
-// input so any trailing '\0' bytes that came from a fixed-width CHAR write
-// are dropped before the value lands in the Field. VARCHAR / STRING ZoneMap
-// values do not normally carry trailing '\0' (the writers store the natural
-// byte length), so strnlen is a no-op for them.
+// from_olap_string cuts a CHAR value at its first '\0': a CHAR(N) write pads
the value
+// with '\0' up to the schema length, and the page read path cuts it the same
way.
+// VARCHAR and STRING are stored with their natural byte length, so they keep
every byte.
TEST_F(OlapTypeTest, from_olap_string_strings) {
struct Case {
PrimitiveType type;
@@ -1884,9 +1882,7 @@ TEST_F(OlapTypeTest, from_olap_string_strings) {
{TYPE_CHAR, std::string("abc", 3) + std::string(7, '\0'), "abc"},
{TYPE_CHAR, std::string(10, '\0'), ""},
{TYPE_CHAR, "alpha", "alpha"},
- // VARCHAR / STRING never carry trailing '\0' in their ZoneMap
- // representation, so the helper is a transparent pass-through
- // for the typical case.
+ // VARCHAR / STRING are handed back byte for byte.
{TYPE_VARCHAR, "hello", "hello"},
{TYPE_STRING, "world\nline2", "world\nline2"},
{TYPE_STRING, "", ""},
@@ -1903,14 +1899,24 @@ TEST_F(OlapTypeTest, from_olap_string_strings) {
}
}
-// VARCHAR / STRING values containing an embedded '\0' are truncated at the
-// first '\0' — the same strnlen behaviour applies to all string types. This
-// is acceptable in practice because Doris string columns do not store
-// embedded NULs in their ZoneMap representation; the test pins the contract.
-TEST_F(OlapTypeTest, from_olap_string_strings_embedded_null_truncates) {
- auto data_type = DataTypeFactory::instance().create_data_type(
- TYPE_VARCHAR, /*is_nullable=*/false, 0, 0, /*length=*/32);
- expect_from_storage_string_paths(data_type, std::string("ab\0cd", 5),
[](const Field& field) {
+// A VARCHAR / STRING value may hold a '\0' in the middle, and every byte has
+// to survive the parse. Cutting at that '\0' used to give a zone map bound
the data never
+// held, which then pruned rows that match. CHAR is still cut, because its
'\0' is padding
+// and the page read path cuts CHAR values the same way.
+TEST_F(OlapTypeTest, from_olap_string_strings_embedded_null) {
+ const std::string embedded_null("ab\0cd", 5);
+
+ for (auto type : {TYPE_VARCHAR, TYPE_STRING}) {
+ auto data_type = DataTypeFactory::instance().create_data_type(type,
/*is_nullable=*/false,
+ 0, 0,
/*length=*/32);
+ expect_from_storage_string_paths(data_type, embedded_null, [&](const
Field& field) {
+ EXPECT_EQ(field.get<TYPE_STRING>(), embedded_null) << "type=" <<
static_cast<int>(type);
+ });
+ }
+
+ auto char_type = DataTypeFactory::instance().create_data_type(TYPE_CHAR,
/*is_nullable=*/false,
+ 0, 0,
/*length=*/5);
+ expect_from_storage_string_paths(char_type, embedded_null, [](const Field&
field) {
EXPECT_EQ(field.get<TYPE_STRING>(), "ab");
});
}
diff --git a/be/test/storage/segment/zone_map_index_test.cpp
b/be/test/storage/segment/zone_map_index_test.cpp
index 17d61ef693d..2ea9503b0b9 100644
--- a/be/test/storage/segment/zone_map_index_test.cpp
+++ b/be/test/storage/segment/zone_map_index_test.cpp
@@ -579,6 +579,74 @@ public:
}
}
}
+
+ // A STRING / VARCHAR value may hold '\0' in the middle, and the zone map
+ // bound has to keep those bytes. A bound cut at the '\0' is smaller than
the data it
+ // stands for, so a pushed-down comparison prunes pages that do hold
matching rows.
+ // CHAR is the exception: it is zero-padded to the schema length on write
and the page
+ // read path cuts every CHAR value at its first '\0', so its bound is cut
here too.
+ template <PrimitiveType PType>
+ void test_embedded_nul_bound(const std::string& testname, bool
bound_is_cut) {
+ // 'a' '\0' 'b' -- a value whose middle byte is '\0'.
+ const std::string value("a\0b", 3);
+ const std::string cut_value("a");
+
+ TabletColumnPtr tab_col;
+ int32_t length = -1;
+ if constexpr (PType == TYPE_CHAR) {
+ length = 3;
+ tab_col = create_char_key(0, false, length);
+ } else if constexpr (PType == TYPE_VARCHAR) {
+ tab_col = create_varchar_key(0, false);
+ } else {
+ tab_col = create_string_key(0, false);
+ }
+ auto data_type = DataTypeFactory::instance().create_data_type(PType,
false, 0, 0, length);
+
+ std::unique_ptr<ZoneMapIndexWriter> writer;
+ ASSERT_TRUE(ZoneMapIndexWriter::create(data_type, tab_col.get(),
writer).ok());
+ Slice slices[] = {Slice(value), Slice(value)};
+ writer->add_values(slices, 2);
+ ASSERT_TRUE(writer->flush().ok());
+
+ const std::string file_path = kTestDir + "/" + testname;
+ io::FileWriterPtr file_writer;
+ ASSERT_TRUE(_fs->create_file(file_path, &file_writer).ok());
+ ColumnIndexMetaPB index_meta;
+ ASSERT_TRUE(writer->finish(file_writer.get(), &index_meta).ok());
+ ASSERT_TRUE(file_writer->close().ok());
+
+ // The bytes on disk always carry the '\0'; only the parse back can
lose it.
+ const auto& seg_zm_pb = index_meta.zone_map_index().segment_zone_map();
+ EXPECT_EQ(seg_zm_pb.min(), value);
+ EXPECT_EQ(seg_zm_pb.max(), value);
+
+ ZoneMap zone_map;
+ ASSERT_TRUE(ZoneMap::from_proto(seg_zm_pb, data_type, zone_map).ok());
+ ASSERT_FALSE(zone_map.pass_all);
+ const std::string& expected = bound_is_cut ? cut_value : value;
+ EXPECT_EQ(zone_map.min_value.template get<PType>(), expected);
+ EXPECT_EQ(zone_map.max_value.template get<PType>(), expected);
+
+ if (bound_is_cut) {
+ return;
+ }
+
+ // The page holds only 'a\0b', so every predicate below has to keep
the page.
+ const auto a = Field::create_field<PType>(cut_value);
+ ComparisonPredicateBase<PType, PredicateType::GT> gt(0, "", a);
+ EXPECT_TRUE(gt.evaluate_and(zone_map));
+ ComparisonPredicateBase<PType, PredicateType::NE> ne(0, "", a);
+ EXPECT_TRUE(ne.evaluate_and(zone_map));
+ ComparisonPredicateBase<PType, PredicateType::EQ> eq(0, "",
+
Field::create_field<PType>(value));
+ EXPECT_TRUE(eq.evaluate_and(zone_map));
+
+ // ... and 'a\0b' <= 'a' matches nothing, so this one may drop it.
+ ComparisonPredicateBase<PType, PredicateType::LE> le(0, "", a);
+ EXPECT_FALSE(le.evaluate_and(zone_map));
+ }
+
io::FileSystemSPtr _fs;
};
@@ -1443,5 +1511,16 @@ TEST_F(ColumnZoneMapTest,
AllNullPageAfterMaxLenStringPage_NoSegmentMaxDoubleInc
EXPECT_EQ(static_cast<unsigned char>(seg_zm.max().back()),
static_cast<unsigned char>('y'));
}
+// Regression test: a comparison predicate on a STRING / VARCHAR column
+// whose values hold an embedded '\0' silently lost or gained rows, because
the zone map
+// bound was parsed back with C string semantics and stopped at that '\0'.
+TEST_F(ColumnZoneMapTest, EmbeddedNulKeepsStringBound) {
+ test_embedded_nul_bound<TYPE_STRING>("embedded_nul_string",
/*bound_is_cut=*/false);
+ test_embedded_nul_bound<TYPE_VARCHAR>("embedded_nul_varchar",
/*bound_is_cut=*/false);
+ // CHAR pads with '\0' on write and cuts at the first '\0' on read, so its
bound is
+ // cut the same way and stays comparable with the rows the page returns.
+ test_embedded_nul_bound<TYPE_CHAR>("embedded_nul_char",
/*bound_is_cut=*/true);
+}
+
} // namespace segment_v2
} // namespace doris
diff --git
a/regression-test/data/datatype_p0/string/test_string_embedded_nul_zonemap.out
b/regression-test/data/datatype_p0/string/test_string_embedded_nul_zonemap.out
new file mode 100644
index 00000000000..99449dcb8cb
--- /dev/null
+++
b/regression-test/data/datatype_p0/string/test_string_embedded_nul_zonemap.out
@@ -0,0 +1,73 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !inside_string_data --
+32 3 3 610062 610062
+
+-- !inside_string_oracle --
+32 32 0
+
+-- !inside_string_gt --
+32
+
+-- !inside_string_ne --
+32
+
+-- !inside_string_le --
+0
+
+-- !inside_string_minmax --
+610062 610062
+
+-- !inside_varchar_data --
+32 3 3 610062 610062
+
+-- !inside_varchar_oracle --
+32 32 0
+
+-- !inside_varchar_gt --
+32
+
+-- !inside_varchar_ne --
+32
+
+-- !inside_varchar_le --
+0
+
+-- !inside_varchar_minmax --
+610062 610062
+
+-- !trailing_string_data --
+32 2 2 6100 6100
+
+-- !trailing_string_oracle --
+32 32 0
+
+-- !trailing_string_gt --
+32
+
+-- !trailing_string_ne --
+32
+
+-- !trailing_string_le --
+0
+
+-- !trailing_string_minmax --
+6100 6100
+
+-- !leading_string_data --
+32 2 2 0061 0061
+
+-- !leading_string_oracle --
+0 32 32
+
+-- !leading_string_gt --
+0
+
+-- !leading_string_ne --
+32
+
+-- !leading_string_le --
+32
+
+-- !leading_string_minmax --
+0061 0061
+
diff --git
a/regression-test/suites/datatype_p0/string/test_string_embedded_nul_zonemap.groovy
b/regression-test/suites/datatype_p0/string/test_string_embedded_nul_zonemap.groovy
new file mode 100644
index 00000000000..aad8036e19f
--- /dev/null
+++
b/regression-test/suites/datatype_p0/string/test_string_embedded_nul_zonemap.groovy
@@ -0,0 +1,79 @@
+// 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.
+
+// A STRING / VARCHAR value may hold a 0x00 byte. The zone map bound used to be
+// parsed back with C string semantics and stopped at that byte, so a
comparison pushed into
+// the scan read a bound the data never held and dropped pages that hold
matching rows.
+//
+// Each table below holds 32 copies of one value, so the segment min and max
are that value.
+// The SUM(...) row is the per-row answer, which never goes through the zone
map; the three
+// COUNT(*) rows run the same predicates through the scan and have to agree
with it.
+suite("test_string_embedded_nul_zonemap") {
+ def load_one_value = { String table, String type, String hex ->
+ sql "DROP TABLE IF EXISTS ${table}"
+ sql """
+ CREATE TABLE ${table} (
+ id INT,
+ s ${type}
+ )
+ DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "disable_auto_compaction" = "true"
+ )
+ """
+ def rows = (1..32).collect { "(${it}, UNHEX('${hex}'))" }.join(", ")
+ sql "INSERT INTO ${table} VALUES ${rows}"
+ }
+
+ // 'a' 0x00 'b' in a STRING column -- the 0x00 sits in the middle.
+ load_one_value("nul_inside_string", "STRING", "610062")
+ qt_inside_string_data "SELECT COUNT(*), MIN(LENGTH(s)), MAX(LENGTH(s)),
MIN(HEX(s)), MAX(HEX(s)) FROM nul_inside_string"
+ qt_inside_string_oracle "SELECT SUM(CAST(s > 'a' AS INT)), SUM(CAST(s !=
'a' AS INT)), SUM(CAST(s <= 'a' AS INT)) FROM nul_inside_string"
+ qt_inside_string_gt "SELECT COUNT(*) FROM nul_inside_string WHERE s > 'a'"
+ qt_inside_string_ne "SELECT COUNT(*) FROM nul_inside_string WHERE s != 'a'"
+ qt_inside_string_le "SELECT COUNT(*) FROM nul_inside_string WHERE s <= 'a'"
+ qt_inside_string_minmax "SELECT HEX(MIN(s)), HEX(MAX(s)) FROM
nul_inside_string"
+
+ // The same value in a VARCHAR column, which shares the zone map bound
path.
+ load_one_value("nul_inside_varchar", "VARCHAR(20)", "610062")
+ qt_inside_varchar_data "SELECT COUNT(*), MIN(LENGTH(s)), MAX(LENGTH(s)),
MIN(HEX(s)), MAX(HEX(s)) FROM nul_inside_varchar"
+ qt_inside_varchar_oracle "SELECT SUM(CAST(s > 'a' AS INT)), SUM(CAST(s !=
'a' AS INT)), SUM(CAST(s <= 'a' AS INT)) FROM nul_inside_varchar"
+ qt_inside_varchar_gt "SELECT COUNT(*) FROM nul_inside_varchar WHERE s >
'a'"
+ qt_inside_varchar_ne "SELECT COUNT(*) FROM nul_inside_varchar WHERE s !=
'a'"
+ qt_inside_varchar_le "SELECT COUNT(*) FROM nul_inside_varchar WHERE s <=
'a'"
+ qt_inside_varchar_minmax "SELECT HEX(MIN(s)), HEX(MAX(s)) FROM
nul_inside_varchar"
+
+ // 'a' 0x00 -- the 0x00 is the last byte, so the whole bound used to
shrink to 'a'.
+ load_one_value("nul_trailing_string", "STRING", "6100")
+ qt_trailing_string_data "SELECT COUNT(*), MIN(LENGTH(s)), MAX(LENGTH(s)),
MIN(HEX(s)), MAX(HEX(s)) FROM nul_trailing_string"
+ qt_trailing_string_oracle "SELECT SUM(CAST(s > 'a' AS INT)), SUM(CAST(s !=
'a' AS INT)), SUM(CAST(s <= 'a' AS INT)) FROM nul_trailing_string"
+ qt_trailing_string_gt "SELECT COUNT(*) FROM nul_trailing_string WHERE s >
'a'"
+ qt_trailing_string_ne "SELECT COUNT(*) FROM nul_trailing_string WHERE s !=
'a'"
+ qt_trailing_string_le "SELECT COUNT(*) FROM nul_trailing_string WHERE s <=
'a'"
+ qt_trailing_string_minmax "SELECT HEX(MIN(s)), HEX(MAX(s)) FROM
nul_trailing_string"
+
+ // 0x00 'a' -- the 0x00 comes first, so the bound used to shrink to the
empty string.
+ load_one_value("nul_leading_string", "STRING", "0061")
+ qt_leading_string_data "SELECT COUNT(*), MIN(LENGTH(s)), MAX(LENGTH(s)),
MIN(HEX(s)), MAX(HEX(s)) FROM nul_leading_string"
+ qt_leading_string_oracle "SELECT SUM(CAST(s > 'a' AS INT)), SUM(CAST(s !=
'a' AS INT)), SUM(CAST(s <= 'a' AS INT)) FROM nul_leading_string"
+ qt_leading_string_gt "SELECT COUNT(*) FROM nul_leading_string WHERE s >
'a'"
+ qt_leading_string_ne "SELECT COUNT(*) FROM nul_leading_string WHERE s !=
'a'"
+ qt_leading_string_le "SELECT COUNT(*) FROM nul_leading_string WHERE s <=
'a'"
+ qt_leading_string_minmax "SELECT HEX(MIN(s)), HEX(MAX(s)) FROM
nul_leading_string"
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]