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 492b9b51b1a branch-4.2: [fix](scan) Read delete-condition columns in 
full #64601 (#69315)
492b9b51b1a is described below

commit 492b9b51b1adfc45ae7e1f766524e78c6588acf3
Author: Chenyang Sun <[email protected]>
AuthorDate: Wed Oct 21 09:54:34 2026 +0800

    branch-4.2: [fix](scan) Read delete-condition columns in full #64601 
(#69315)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: #64601
    
    Problem Summary: Backport #64601 to branch-4.2. Queries that only need a
    dictionary-encoded VARCHAR column's length, including empty-string
    comparisons rewritten to length checks, may read only offsets. If the
    same column has a storage delete condition, evaluating that condition
    against content-less values can return deleted rows. Remove
    delete-condition columns from all_access_paths so their predicates use
    fully materialized values.
    
    Resolve the branch conflicts by preserving condition_cache_digest and
    merging the two delete-column unit tests into the existing
    TabletReaderTest fixture without changing its TopN tests. The regression
    suite and generated expected output are unchanged from the source
    commit.
    
    ### Release note
    
    Fix queries using string lengths or empty-string comparisons returning
    rows excluded by storage delete conditions.
    
    ### Check List (For Author)
    
    - Test: clang-format 16 via build-support/clang-format.sh and
    build-support/check-format.sh passed for all three changed C++ files;
    source/suite whitespace and conflict-preservation checks passed.
    - Unit Test / Regression test: backported both unit tests and the
    regression suite; not executed locally. An ASAN build was started with
    build.sh --be -j 24 and stopped during first-time submodule
    initialization. Compilation and runtime tests require CI validation.
    - The generated .out file is byte-for-byte identical to #64601; trailing
    tabs represent empty string values.
    - branch-4.2 does not contain check-build-hygiene.sh or
    run-clang-tidy.sh, so those scripts were unavailable.
    - Behavior changed: Yes; delete-condition columns use a full read so
    deleted rows remain filtered.
    - Does this need documentation: No.
---
 be/src/storage/tablet/tablet_reader.cpp            | 27 ++++++++
 be/src/storage/tablet/tablet_reader.h              |  7 ++
 be/test/storage/tablet_reader_test.cpp             | 78 ++++++++++++++++++++++
 .../delete_p0/test_offset_only_delete_leak.out     | 22 ++++++
 .../delete_p0/test_offset_only_delete_leak.groovy  | 57 ++++++++++++++++
 5 files changed, 191 insertions(+)

diff --git a/be/src/storage/tablet/tablet_reader.cpp 
b/be/src/storage/tablet/tablet_reader.cpp
index 6b1ede06ac1..e2e97e0c3be 100644
--- a/be/src/storage/tablet/tablet_reader.cpp
+++ b/be/src/storage/tablet/tablet_reader.cpp
@@ -27,7 +27,9 @@
 #include <memory>
 #include <numeric>
 #include <ostream>
+#include <set>
 #include <shared_mutex>
+#include <unordered_map>
 
 #include "common/compiler_util.h" // IWYU pragma: keep
 #include "common/config.h"
@@ -43,10 +45,12 @@
 #include "runtime/query_context.h"
 #include "runtime/runtime_predicate.h"
 #include "runtime/runtime_state.h"
+#include "storage/delete/delete_handler.h"
 #include "storage/index/bloom_filter/bloom_filter.h"
 #include "storage/itoken_extractor.h"
 #include "storage/olap_common.h"
 #include "storage/olap_define.h"
+#include "storage/predicate/block_column_predicate.h"
 #include "storage/predicate/column_predicate.h"
 #include "storage/predicate/like_column_predicate.h"
 #include "storage/predicate/predicate_creator.h"
@@ -80,6 +84,21 @@ Status TabletReader::init(const ReaderParams& read_params) {
     return res;
 }
 
+void TabletReader::remove_delete_columns_from_access_paths(
+        const DeleteHandler& delete_handler, const TabletSchema& tablet_schema,
+        std::map<int32_t, TColumnAccessPaths>& all_access_paths) {
+    auto delete_predicates = AndBlockColumnPredicate::create_shared();
+    std::unordered_map<int32_t, std::vector<std::shared_ptr<const 
ColumnPredicate>>>
+            del_predicates_for_zone_map;
+    delete_handler.get_delete_conditions_after_version(0, 
delete_predicates.get(),
+                                                       
&del_predicates_for_zone_map);
+    std::set<ColumnId> delete_column_ids;
+    delete_predicates->get_all_column_ids(delete_column_ids);
+    for (auto cid : delete_column_ids) {
+        all_access_paths.erase(tablet_schema.column(cid).unique_id());
+    }
+}
+
 Status TabletReader::_capture_rs_readers(const ReaderParams& read_params) {
     SCOPED_RAW_TIMER(&_stats.tablet_reader_capture_rs_readers_timer_ns);
     if (read_params.rs_splits.empty()) {
@@ -195,6 +214,14 @@ Status TabletReader::_capture_rs_readers(const 
ReaderParams& read_params) {
     _reader_context.all_access_paths = read_params.all_access_paths;
     _reader_context.predicate_access_paths = 
read_params.predicate_access_paths;
 
+    // Force a full read of delete-condition columns: the FE can't see storage 
deletes and may
+    // mark them meta-only (OFFSET/NULL), whose content-less read makes the 
delete predicate
+    // match nothing and leak deleted rows.
+    if (!_delete_handler.empty() && !_reader_context.all_access_paths.empty()) 
{
+        remove_delete_columns_from_access_paths(_delete_handler, 
*_tablet_schema,
+                                                
_reader_context.all_access_paths);
+    }
+
     _reader_context.condition_cache_digest = 
read_params.condition_cache_digest;
     // Propagate general read limit for DUP_KEYS and UNIQUE_KEYS with MOW
     _reader_context.general_read_limit = read_params.general_read_limit;
diff --git a/be/src/storage/tablet/tablet_reader.h 
b/be/src/storage/tablet/tablet_reader.h
index d5798cc618d..f9fc02966e1 100644
--- a/be/src/storage/tablet/tablet_reader.h
+++ b/be/src/storage/tablet/tablet_reader.h
@@ -264,6 +264,13 @@ public:
             const std::vector<RowsetSharedPtr>& input_rowsets,
             TabletReader::ReaderParams* reader_params, Block* block);
 
+    // Remove the delete-condition columns from `all_access_paths` so they 
fall back to a full
+    // read (a meta-only read would make the storage delete predicate match 
nothing and leak
+    // deleted rows).
+    static void remove_delete_columns_from_access_paths(
+            const DeleteHandler& delete_handler, const TabletSchema& 
tablet_schema,
+            std::map<int32_t, TColumnAccessPaths>& all_access_paths);
+
 protected:
     friend class VCollectIterator;
     friend class DeleteHandler;
diff --git a/be/test/storage/tablet_reader_test.cpp 
b/be/test/storage/tablet_reader_test.cpp
index 602e06438e7..87d3b11ea34 100644
--- a/be/test/storage/tablet_reader_test.cpp
+++ b/be/test/storage/tablet_reader_test.cpp
@@ -20,11 +20,14 @@
 #include <gen_cpp/olap_file.pb.h>
 #include <gtest/gtest.h>
 
+#include <map>
 #include <memory>
 #include <string>
 #include <utility>
 #include <vector>
 
+#include "storage/delete/delete_handler.h"
+#include "storage/rowset/rowset_meta.h"
 #include "storage/storage_engine.h"
 #include "storage/tablet/tablet.h"
 #include "storage/tablet/tablet_meta.h"
@@ -73,6 +76,16 @@ protected:
         params.read_orderby_key_limit = 10;
         return reader._init_orderby_keys_param(params);
     }
+
+    // Build a DeleteHandler initialized with a single delete-predicate rowset.
+    static void init_delete_handler(DeleteHandler& handler, const 
TabletSchemaSPtr& schema,
+                                    const DeletePredicatePB& delete_predicate) 
{
+        auto rs_meta = std::make_shared<RowsetMeta>();
+        rs_meta->set_tablet_schema(schema);
+        rs_meta->set_version(Version(2, 2));
+        rs_meta->set_delete_predicate(delete_predicate);
+        ASSERT_TRUE(handler.init(schema, {rs_meta}, /*version=*/100).ok());
+    }
 };
 
 // DUP and merge-on-write tablets give storage TopN the leading key columns to 
sort by.
@@ -113,4 +126,69 @@ TEST_F(TabletReaderTest, 
storage_topn_without_orderby_key_columns_fails) {
     st = plain_reader._init_orderby_keys_param(TabletReader::ReaderParams {});
     EXPECT_TRUE(st.ok()) << st;
 }
+
+// The delete columns (resolved by the delete handler to field ids) are mapped 
back to their
+// column unique ids and stripped from all_access_paths; unrelated columns 
keep their paths.
+TEST_F(TabletReaderTest, remove_delete_columns_from_access_paths) {
+    auto schema = create_schema({{"k1", 10}, {"k2", 11}, {"v1", 12}, {"v2", 
13}, {"v4", 15}});
+
+    DeletePredicatePB delete_predicate;
+    auto* p1 = delete_predicate.add_sub_predicates_v2(); // k1 -> erase 10
+    p1->set_column_name("k1");
+    p1->set_column_unique_id(10);
+    p1->set_op("=");
+    p1->set_cond_value("1");
+    auto* p2 = delete_predicate.add_sub_predicates_v2(); // v1 -> erase 12
+    p2->set_column_name("v1");
+    p2->set_column_unique_id(12);
+    p2->set_op("=");
+    p2->set_cond_value("2");
+    auto* in1 = delete_predicate.add_in_predicates(); // k2 IN (...) -> erase 
11
+    in1->set_column_name("k2");
+    in1->set_column_unique_id(11);
+    in1->set_is_not_in(false);
+    in1->add_values("3");
+    in1->add_values("4");
+
+    DeleteHandler handler;
+    init_delete_handler(handler, schema, delete_predicate);
+
+    std::map<int32_t, TColumnAccessPaths> access_paths;
+    for (int32_t uid : {10, 11, 12, 13, 15}) {
+        access_paths[uid] = TColumnAccessPaths {};
+    }
+
+    TabletReader::remove_delete_columns_from_access_paths(handler, *schema, 
access_paths);
+
+    EXPECT_EQ(size_t(2), access_paths.size());
+    EXPECT_EQ(size_t(1), access_paths.count(13)) << "non-delete column must 
keep its access path";
+    EXPECT_EQ(size_t(1), access_paths.count(15)) << "non-delete column must 
keep its access path";
+    for (int32_t uid : {10, 11, 12}) {
+        EXPECT_EQ(size_t(0), access_paths.count(uid)) << "delete column " << 
uid << " not erased";
+    }
+}
+
+// A delete condition whose column has no access path leaves the map untouched.
+TEST_F(TabletReaderTest, remove_delete_columns_keeps_unrelated_paths) {
+    auto schema = create_schema({{"k1", 10}, {"v1", 12}});
+
+    DeletePredicatePB delete_predicate;
+    auto* p1 = delete_predicate.add_sub_predicates_v2();
+    p1->set_column_name("k1");
+    p1->set_column_unique_id(10);
+    p1->set_op("=");
+    p1->set_cond_value("1");
+
+    DeleteHandler handler;
+    init_delete_handler(handler, schema, delete_predicate);
+
+    std::map<int32_t, TColumnAccessPaths> access_paths;
+    access_paths[12] = TColumnAccessPaths {};
+    access_paths[15] = TColumnAccessPaths {};
+
+    TabletReader::remove_delete_columns_from_access_paths(handler, *schema, 
access_paths);
+
+    EXPECT_EQ(size_t(2), access_paths.size());
+}
+
 } // namespace doris
diff --git a/regression-test/data/delete_p0/test_offset_only_delete_leak.out 
b/regression-test/data/delete_p0/test_offset_only_delete_leak.out
new file mode 100644
index 00000000000..69a018babd7
--- /dev/null
+++ b/regression-test/data/delete_p0/test_offset_only_delete_leak.out
@@ -0,0 +1,22 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !count --
+7
+
+-- !length_ge_0 --
+7
+
+-- !length_gt_0 --
+5
+
+-- !empty_string --
+2
+
+-- !full_read --
+10     aa
+2      aa
+4      bb
+5      
+6      aa
+8      cc
+9      
+
diff --git 
a/regression-test/suites/delete_p0/test_offset_only_delete_leak.groovy 
b/regression-test/suites/delete_p0/test_offset_only_delete_leak.groovy
new file mode 100644
index 00000000000..e3c8d4b0672
--- /dev/null
+++ b/regression-test/suites/delete_p0/test_offset_only_delete_leak.groovy
@@ -0,0 +1,57 @@
+// 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.
+
+// When a query only needs a column's length (length(v), or v='' which is 
rewritten to
+// length=0), a dict-encoded varchar column is read in OFFSET_ONLY mode, which 
only
+// materializes lengths and leaves the char content empty. If that same column 
also
+// carries a DELETE condition, the storage-side delete predicate compares the 
empty
+// content, matches nothing, and the deleted rows leak back into the scan 
output.
+suite("test_offset_only_delete_leak", "p0") {
+    sql "drop table if exists test_offset_only_delete_leak"
+    sql """
+        create table test_offset_only_delete_leak (
+            k int,
+            v varchar(50)
+        ) duplicate key(k)
+        distributed by hash(k) buckets 1
+        properties("replication_num" = "1")
+    """
+
+    // low cardinality -> the column gets dictionary encoded
+    sql """
+        insert into test_offset_only_delete_leak values
+        (1,'--'),(2,'aa'),(3,'--'),(4,'bb'),(5,''),
+        (6,'aa'),(7,'--'),(8,'cc'),(9,''),(10,'aa')
+    """
+
+    // delete the three '--' rows (k = 1,3,7); kept as a delete predicate 
applied at read time.
+    sql "delete from test_offset_only_delete_leak where v = '--'"
+
+    // plain count does not read v.
+    qt_count "select count(*) from test_offset_only_delete_leak"
+
+    // length(v) only needs v's length -> OFFSET_ONLY read; the delete on v 
must still apply,
+    // so the deleted '--' rows must not be counted.
+    qt_length_ge_0 "select count(*) from test_offset_only_delete_leak where 
length(v) >= 0"
+    qt_length_gt_0 "select count(*) from test_offset_only_delete_leak where 
length(v) > 0"
+
+    // v = '' is rewritten to length(v) = 0 -> also OFFSET_ONLY.
+    qt_empty_string "select count(*) from test_offset_only_delete_leak where v 
= ''"
+
+    // outputting v forces a full read; the deleted rows must never appear 
either way.
+    order_qt_full_read "select k, v from test_offset_only_delete_leak where 
length(v) >= 0 order by k, v"
+}


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

Reply via email to