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

gavinchou 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 369f887b254 [fix](table stream) Fix multi-segment row-binlog reads for 
MoW tables (#66338)
369f887b254 is described below

commit 369f887b254d9b2e7ecd7fc4a077e19588414ef2
Author: TsukiokaKogane <[email protected]>
AuthorDate: Tue Aug 25 12:04:33 2026 +0800

    [fix](table stream) Fix multi-segment row-binlog reads for MoW tables 
(#66338)
    
    ### What problem does this PR solve?
    
    Issue Number: close #65901
    
    Related PR: #65418
    
    Problem Summary:
    
    Table Stream queries over ROW binlog could crash or return incorrect
    results when a row-binlog rowset contained multiple segments. This
    affected both multi-segment rowsets generated by load and non-singleton
    overlapping rowsets produced by row-binlog LMax quick merge. Although
    the source table may use UNIQUE_KEYS with Merge-on-Write, the scan reads
    from its dedicated DUP_KEYS row-binlog tablet; the problem was caused by
    inconsistent iterator selection and incorrect ordering and grouping
    assumptions.
    
    For a non-overlapping multi-segment rowset, `force_key_ordered_read`
    caused `BetaRowsetReader` to build a merge iterator solely because
    ordered output was required. The outer `VCollectIterator` could still
    select its block-based union path because the rowset was
    non-overlapping. This mismatch made the child iterator enter
    by-reference mode while its parent called the block interface,
    triggering the `!_get_data_by_ref` assertion and aborting the BE.
    Non-overlapping segments do not require a merge iterator because an
    ordered union already preserves key order.
    
    Row-binlog LMax quick merge introduces another multi-segment layout: it
    links segments from multiple rowsets into a non-singleton rowset marked
    `OVERLAPPING`. The previous `is_segments_overlapping()` implementation
    only recognized singleton delta rowsets, so these quick-merge rowsets
    could incorrectly use the union path instead of merging their segments
    by key and TSO. The TSO column could also be skipped after zone-map
    pruning even though it is required as the merge sequence column.
    
    In addition, MIN_DELTA processing used `IteratorRowRef::is_same` to
    identify consecutive events belonging to the same user key. This flag is
    a merge/dedup marker and does not reliably mark same-key events that
    reside in one segment after quick merge. As a result, one key's change
    chain could be split into multiple groups and folded incorrectly.
    
    This change separates ordered-output requirements from iterator
    selection: non-overlapping segments use the ordered union path, while
    segments explicitly marked as overlapping use the merge path. It
    recognizes non-singleton overlapping rowsets created by quick merge,
    always materializes the row-binlog TSO column when it is needed for
    ordering, and groups MIN_DELTA events by comparing their user-key
    columns directly.
    
    Before this change, affected queries could abort the BE, read
    quick-merge rowsets out of order, or produce incorrect MIN_DELTA
    results. After this change, multi-segment row-binlog rowsets are read
    through a consistent iterator interface and processed in key/TSO order
    with correct per-key change folding.
---
 be/src/storage/iterator/block_reader.cpp           |  28 +-
 be/src/storage/merger.cpp                          |   6 +
 be/src/storage/rowset/beta_rowset_reader.h         |   3 +-
 be/src/storage/rowset/beta_rowset_writer.cpp       |  11 +
 be/src/storage/rowset/rowset_meta.h                |   5 +-
 be/src/storage/rowset/rowset_reader_context.h      |   1 -
 be/src/storage/segment/segment_iterator.cpp        |   9 +
 be/src/storage/tablet/tablet_reader.cpp            |   3 +-
 be/src/storage/tablet/tablet_reader.h              |   7 +-
 be/test/exec/scan/vgeneric_iterators_test.cpp      | 165 ++++++-
 .../block_reader_binlog_vcollect_merge_test.cpp    | 402 +++++++++++++++
 .../iterator/block_reader_min_delta_test.cpp       | 100 ++++
 .../storage/row_binlog_vmerge_compaction_test.cpp  | 400 +++++++++++++++
 be/test/storage/rowset/rowset_meta_test.cpp        |  54 +++
 be/test/storage/tablet_reader_test.cpp             |   6 +-
 .../test_table_stream_multi_segment_mow.out        |  79 +++
 .../test_table_stream_multi_segment_mow.groovy     | 538 +++++++++++++++++++++
 17 files changed, 1800 insertions(+), 17 deletions(-)

diff --git a/be/src/storage/iterator/block_reader.cpp 
b/be/src/storage/iterator/block_reader.cpp
index 29aa476bd0e..e91b16cd021 100644
--- a/be/src/storage/iterator/block_reader.cpp
+++ b/be/src/storage/iterator/block_reader.cpp
@@ -216,6 +216,11 @@ Status BlockReader::_min_delta_next_block(Block* block, 
bool* eof) {
     const int32_t tso_ordinal = _read_schema->tso_ordinal();
     const int32_t lsn_ordinal = _read_schema->lsn_ordinal();
     const int32_t op_ordinal = _read_schema->op_ordinal();
+    // A group is a run of consecutive rows sharing the same user key. 
Row-binlog reads are
+    // globally key-ordered (ReaderParams::force_key_ordered_read), and the 
key columns are the
+    // leading num_key_columns() columns of every read block, so a group 
boundary is exactly
+    // where the user key changes. _stored_data_columns keeps the group's 
first row at index 0.
+    const size_t num_key_columns = _tablet_schema->num_key_columns();
     while (output_row_count < batch_max_rows()) {
         if (_emit_pending_row(target_columns, output_row_count)) {
             continue;
@@ -238,8 +243,27 @@ Status BlockReader::_min_delta_next_block(Block* block, 
bool* eof) {
             return res;
         }
 
-        if (!_eof && _next_row.is_same) {
-            continue;
+        // Extend the current group while the next row shares the same user 
key. is_same cannot
+        // be used here: it marks cross-segment key matches for dedup, so 
consecutive same-key
+        // rows that a compaction/quick-merge folded into one segment are left 
unmarked, which
+        // would split one key's change chain into several groups. Compare the 
leading key
+        // columns directly against the group's first row (index 0 of 
_stored_data_columns).
+        if (!_eof) {
+            if (_next_row.is_same) {
+                continue;
+            }
+            bool same_key = true;
+            for (size_t k = 0; k < num_key_columns; ++k) {
+                if (_stored_data_columns[k]->compare_at(0, _next_row.row_pos,
+                                                        
*_next_row.block->get_by_position(k).column,
+                                                        -1) != 0) {
+                    same_key = false;
+                    break;
+                }
+            }
+            if (same_key) {
+                continue;
+            }
         }
         size_t group_size = _stored_data_columns[0]->size();
         auto first_op = _read_binlog_op(*_stored_data_columns[op_ordinal], 0);
diff --git a/be/src/storage/merger.cpp b/be/src/storage/merger.cpp
index 45cf07f045b..dfcd333cad5 100644
--- a/be/src/storage/merger.cpp
+++ b/be/src/storage/merger.cpp
@@ -77,6 +77,12 @@ Status Merger::vmerge_rowsets(BaseTabletSPtr tablet, 
ReaderType reader_type,
     reader_params.tablet = tablet;
     reader_params.reader_type = reader_type;
     reader_params.read_row_binlog = tablet->is_row_binlog_tablet();
+    if (reader_params.read_row_binlog) {
+        // Row-binlog horizontal (non-vertical) compaction must produce a 
globally
+        // (key, TSO)-ordered output.
+        reader_params.read_orderby_key = true;
+        reader_params.force_key_ordered_read = true;
+    }
 
     TabletReadSource read_source;
     read_source.rs_splits.reserve(src_rowset_readers.size());
diff --git a/be/src/storage/rowset/beta_rowset_reader.h 
b/be/src/storage/rowset/beta_rowset_reader.h
index c1e2d96224b..c223a62089d 100644
--- a/be/src/storage/rowset/beta_rowset_reader.h
+++ b/be/src/storage/rowset/beta_rowset_reader.h
@@ -61,8 +61,7 @@ public:
 
     bool is_merge_iterator() const override {
         return _read_context->need_ordered_result && _get_segment_num() > 1 &&
-               (_rowset->rowset_meta()->is_segments_overlapping() ||
-                _read_context->force_key_ordered_read);
+               _rowset->rowset_meta()->is_segments_overlapping();
     }
 
     bool delete_flag() override { return _rowset->delete_flag(); }
diff --git a/be/src/storage/rowset/beta_rowset_writer.cpp 
b/be/src/storage/rowset/beta_rowset_writer.cpp
index f967b8f49c0..013a49c1890 100644
--- a/be/src/storage/rowset/beta_rowset_writer.cpp
+++ b/be/src/storage/rowset/beta_rowset_writer.cpp
@@ -1074,6 +1074,17 @@ Status 
BaseBetaRowsetWriter::_build_rowset_meta(RowsetMeta* rowset_meta, bool ch
         !is_segment_overlapping(segments_encoded_key_bounds) &&
         _context.tablet_schema->cluster_key_uids().empty()) {
         rowset_meta->set_segments_overlap(NONOVERLAPPING);
+    } else if (!segments_encoded_key_bounds.empty() &&
+               is_segment_overlapping(segments_encoded_key_bounds) &&
+               _rowset_meta->is_row_binlog()) {
+        // A row-binlog horizontal merge output can hold the same user key in 
more than
+        // one output segment (a forced segment boundary splits a key range), 
so the
+        // segments genuinely overlap. The compaction ctx default is 
NONOVERLAPPING for
+        // the non-quick-merge path, and the branch above only ever 
downgrades, so upgrade
+        // here. Otherwise RowsetMeta::is_segments_overlapping() and
+        // BetaRowsetReader::is_merge_iterator() would pick a segment union 
instead of a
+        // merge and break downstream MIN_DELTA reads.
+        rowset_meta->set_segments_overlap(OVERLAPPING);
     }
 
     auto segment_num = _num_seg();
diff --git a/be/src/storage/rowset/rowset_meta.h 
b/be/src/storage/rowset/rowset_meta.h
index fde75545091..e73842368cf 100644
--- a/be/src/storage/rowset/rowset_meta.h
+++ b/be/src/storage/rowset/rowset_meta.h
@@ -306,8 +306,11 @@ public:
     // 2. the rowset's start version == end version (non-singleton rowset was 
generated by compaction process
     //    which always produces non-overlapped segments)
     // 3. segments_overlap() flag is not NONOVERLAPPING (OVERLAP_UNKNOWN and 
OVERLAPPING are OK)
+    // 4. relaxing is_singleton_delta() for is_row_binlog as Row-binlog LMax 
quick merge does produce
+    // non-singleton overlapping segments, but it writes segments_overlap = 
OVERLAPPING explicitly
     bool is_segments_overlapping() const {
-        return num_segments() > 1 && is_singleton_delta() && 
segments_overlap() != NONOVERLAPPING;
+        return num_segments() > 1 && segments_overlap() != NONOVERLAPPING &&
+               (is_singleton_delta() || (is_row_binlog() && segments_overlap() 
== OVERLAPPING));
     }
 
     bool produced_by_compaction() const {
diff --git a/be/src/storage/rowset/rowset_reader_context.h 
b/be/src/storage/rowset/rowset_reader_context.h
index 90f522c99ad..5f8e536a655 100644
--- a/be/src/storage/rowset/rowset_reader_context.h
+++ b/be/src/storage/rowset/rowset_reader_context.h
@@ -52,7 +52,6 @@ struct RowsetReaderContext {
     // For rows with the same key, use ascending order (small-to-large) for 
tie-breakers.
     // For example, use lower rowset version / segment id first.
     bool use_insert_order_when_same = false;
-    bool force_key_ordered_read = false;
     // ordinals (positions in read_schema) of the orderby key columns
     std::vector<uint32_t>* read_orderby_key_columns = nullptr;
     // limit of rows for read_orderby_key
diff --git a/be/src/storage/segment/segment_iterator.cpp 
b/be/src/storage/segment/segment_iterator.cpp
index d1464cc43c9..08ec84d700f 100644
--- a/be/src/storage/segment/segment_iterator.cpp
+++ b/be/src/storage/segment/segment_iterator.cpp
@@ -1561,6 +1561,15 @@ bool SegmentIterator::_need_read_data(ColumnId cid) {
         // occurring, return true here that column data needs to be read
         return true;
     }
+    // Row-binlog incremental reads force-push the TSO range predicate (see
+    // OlapScanner::_init_tso_predicate), and the merge iterator uses the TSO 
column as its
+    // sequence sort key (BetaRowsetReader sets binlog_tso_idx). On a 
cross-version rowset
+    // (e.g. produced by binlog LMax quick-merge) the TSO zonemap can be 
always-true, so the
+    // pruning below would skip reading it and fill placeholder zeros, 
breaking the merge
+    // ordering. The TSO column carries real values on disk, so force it to be 
read.
+    if (_opts.read_row_binlog && cid == _schema->tso_ordinal()) {
+        return true;
+    }
     const auto& column = *_schema->column(cid);
     // Different subcolumns may share the same parent_unique_id, so we choose 
to abandon this optimization.
     if (column.is_extracted_column() &&
diff --git a/be/src/storage/tablet/tablet_reader.cpp 
b/be/src/storage/tablet/tablet_reader.cpp
index 8986bf0b136..38eae53632f 100644
--- a/be/src/storage/tablet/tablet_reader.cpp
+++ b/be/src/storage/tablet/tablet_reader.cpp
@@ -159,12 +159,11 @@ Status TabletReader::_capture_rs_readers(const 
ReaderParams& read_params) {
     _reader_context.read_row_binlog = read_params.read_row_binlog;
     _reader_context.version = read_params.version;
     _reader_context.tablet_schema = _tablet_schema;
-    _reader_context.need_ordered_result = need_ordered_result;
+    _reader_context.need_ordered_result = need_ordered_result || 
read_params.force_key_ordered_read;
     _reader_context.topn_filter_source_node_ids = 
read_params.topn_filter_source_node_ids;
     _reader_context.read_orderby_key_reverse = 
read_params.read_orderby_key_reverse;
     _reader_context.use_insert_order_when_same =
             read_params.use_insert_order_when_same || 
read_params.read_row_binlog;
-    _reader_context.force_key_ordered_read = 
read_params.force_key_ordered_read;
     _reader_context.read_orderby_key_limit = 
read_params.read_orderby_key_limit;
     _reader_context.read_schema = _read_schema;
     _reader_context.read_orderby_key_columns =
diff --git a/be/src/storage/tablet/tablet_reader.h 
b/be/src/storage/tablet/tablet_reader.h
index b8a9f6366c4..03a15982d54 100644
--- a/be/src/storage/tablet/tablet_reader.h
+++ b/be/src/storage/tablet/tablet_reader.h
@@ -183,10 +183,9 @@ public:
         // For rows with the same key, use ascending order (small-to-large) 
for tie-breakers.
         // For example, use lower rowset version / segment id first.
         bool use_insert_order_when_same = false;
-        // Force a key-ordered merge across all segments even when their key 
ranges do not
-        // overlap. By default a rowset reader can skip the merge heap if its 
segments are
-        // mono-ascending and disjoint, but row-binlog scans require strict 
global key order
-        // (e.g. so MIN_DELTA can group consecutive same-key changes), so this 
flag is set.
+        // Force globally key-ordered reading for row-binlog scans (e.g. so 
MIN_DELTA can
+        // group consecutive same-key changes across segments). Overlapping 
segments use the
+        // merge iterator; segments proven non-overlapping use an ordered 
union.
         // See BetaRowsetReader::is_merge_iterator() in 
beta_rowset_reader.h:62.
         bool force_key_ordered_read = false;
         // num of columns for orderby key
diff --git a/be/test/exec/scan/vgeneric_iterators_test.cpp 
b/be/test/exec/scan/vgeneric_iterators_test.cpp
index d55b96b9ce8..1299057cb50 100644
--- a/be/test/exec/scan/vgeneric_iterators_test.cpp
+++ b/be/test/exec/scan/vgeneric_iterators_test.cpp
@@ -50,7 +50,7 @@ public:
     virtual ~VGenericIteratorsTest() {}
 };
 
-static ReadSchema create_schema() {
+static std::vector<TabletColumnPtr> create_col_schemas() {
     std::vector<TabletColumnPtr> col_schemas;
     auto c1 = 
std::make_shared<TabletColumn>(FieldAggregationMethod::OLAP_FIELD_AGGREGATION_NONE,
                                              
FieldType::OLAP_FIELD_TYPE_SMALLINT, true);
@@ -65,7 +65,11 @@ static ReadSchema create_schema() {
     col_schemas.emplace_back(
             
std::make_shared<TabletColumn>(FieldAggregationMethod::OLAP_FIELD_AGGREGATION_SUM,
                                            FieldType::OLAP_FIELD_TYPE_BIGINT, 
true));
-    return ReadSchema(std::move(col_schemas));
+    return col_schemas;
+}
+
+static ReadSchema create_schema() {
+    return ReadSchema(create_col_schemas());
 }
 
 static void create_block(ReadSchema& schema, Block& block) {
@@ -474,4 +478,161 @@ TEST(VGenericIteratorsTest, 
MergeWithSeqColumnSmallSeqFirst) {
     EXPECT_EQ(0, actual_value);
 }
 
+// Models the row-binlog quick-merge read path: the merge output rowset holds 
the
+// same user key across multiple segments, each carrying a distinct binlog 
event
+// (distinct TSO). A row-binlog scan reads it with is_unique=false and the TSO 
as
+// the sequence column with small_seq_first=true. Unlike the is_unique=true 
cases
+// above (which collapse same-key rows to one), the merge iterator must keep 
EVERY
+// event and emit them ordered by ascending TSO, so a table-stream query over a
+// quick-merge rowset returns the complete, ordered change chain without 
dropping
+// any event.
+TEST(VGenericIteratorsTest, MergeKeepsAllBinlogEventsOrderedByTso) {
+    auto schema = create_schema();
+    auto output_schema = std::make_shared<ReadSchema>(schema);
+    std::vector<RowwiseIteratorUPtr> inputs;
+
+    int seq_column_id = 2; // BIGINT column stands in for __DORIS_BINLOG_TSO__.
+    int num_rows = 1;
+    int rows_begin = 0;
+    // Same key in every segment, each carrying a distinct TSO. The children 
are fed in a
+    // deterministic SHUFFLED TSO order (not ascending), so the test only 
passes if the
+    // merge actually orders by the TSO sequence column; a comparator that 
ignored the
+    // sequence and fell back to child/data-id arrival order would emit this 
shuffled
+    // sequence unchanged and fail the ascending-order assertion below.
+    const std::vector<int> tso_feed_order = {7, 1, 9, 0, 5, 8, 2, 6, 4, 3};
+    int seg_iter_num = static_cast<int>(tso_feed_order.size());
+    for (int tso : tso_feed_order) {
+        inputs.push_back(std::make_unique<SeqColumnUtIterator>(schema, 
num_rows, rows_begin,
+                                                               seq_column_id, 
tso));
+    }
+
+    // is_unique=false keeps every same-key event; small_seq_first=true orders 
by
+    // ascending TSO. This mirrors beta_rowset_reader's row-binlog merge setup.
+    auto iter = new_merge_iterator(std::move(inputs), seq_column_id, 
/*is_unique=*/false,
+                                   /*is_reverse=*/false, 
/*merged_rows=*/nullptr, output_schema,
+                                   /*small_seq_first=*/true);
+    StorageReadOptions opts;
+    auto st = iter->init(opts);
+    EXPECT_TRUE(st.ok());
+
+    Block block;
+    std::vector<bool> row_is_same;
+    BlockWithSameBit block_with_same_bit {.block = &block, .same_bit = 
row_is_same};
+    create_block(schema, block);
+
+    do {
+        st = iter->next_batch(&block_with_same_bit);
+    } while (st.ok());
+
+    EXPECT_TRUE(st.is<END_OF_FILE>());
+    // Every event survives: no per-key dedup.
+    EXPECT_EQ(block.rows(), seg_iter_num);
+
+    // Despite the shuffled input order, events are emitted in ascending TSO 
order.
+    auto seq_col = block.get_by_position(seq_column_id).column;
+    for (int i = 0; i < seg_iter_num; ++i) {
+        EXPECT_EQ(i, (*seq_col)[i].get<TYPE_BIGINT>());
+    }
+}
+
+// Emits num_rows rows for a schema whose projection (col_ids) may be narrower 
than the
+// full tablet schema. The filled block layout follows the projection, 
matching how
+// VMergeIteratorContext::block_reset builds its block from the output schema.
+class ProjectedColumnsUtIterator : public RowwiseIterator {
+public:
+    ProjectedColumnsUtIterator(ReadSchema schema, size_t num_rows)
+            : _schema(std::move(schema)), _num_rows(num_rows) {}
+    ~ProjectedColumnsUtIterator() override = default;
+
+    Status init(const StorageReadOptions& opts) override { return 
Status::OK(); }
+
+    Status next_batch(Block* block) override {
+        if (_rows_returned >= _num_rows) {
+            return Status::EndOfFile("End of ProjectedColumnsUtIterator");
+        }
+        while (_rows_returned < _num_rows) {
+            for (size_t j = 0; j < _schema.num_block_columns(); ++j) {
+                ColumnWithTypeAndName& vc = block->get_by_position(j);
+                IColumn& vi = (IColumn&)(*vc.column);
+
+                char data[16] = {};
+                size_t data_len = 0;
+                const auto* col_schema = _schema.column(j);
+                switch (col_schema->type()) {
+                case FieldType::OLAP_FIELD_TYPE_SMALLINT:
+                    *(int16_t*)data = static_cast<int16_t>(_rows_returned);
+                    data_len = sizeof(int16_t);
+                    break;
+                case FieldType::OLAP_FIELD_TYPE_INT:
+                    *(int32_t*)data = static_cast<int32_t>(_rows_returned);
+                    data_len = sizeof(int32_t);
+                    break;
+                case FieldType::OLAP_FIELD_TYPE_BIGINT:
+                    *(int64_t*)data = static_cast<int64_t>(_rows_returned);
+                    data_len = sizeof(int64_t);
+                    break;
+                default:
+                    break;
+                }
+
+                vi.insert_data(data, data_len);
+            }
+            ++_rows_returned;
+        }
+        return Status::OK();
+    }
+
+    const ReadSchema& schema() const override { return _schema; }
+
+private:
+    ReadSchema _schema;
+    size_t _num_rows;
+    size_t _rows_returned = 0;
+};
+
+// The merge-heap comparator compares the leading num_key_columns() block 
positions when no
+// explicit compare columns are given. The read schema must therefore place 
its key columns as
+// the leading prefix; if a non-key column precedes a key column, init() must 
reject the merge
+// with a "compare contract violated" error instead of silently comparing a 
value column as a key
+// (issue #66390: a ROW-binlog APPEND_ONLY scan projected value columns ahead 
of a key).
+TEST(VGenericIteratorsTest, MergeRejectsProjectionMissingKeyPrefix) {
+    // Full tablet schema: k0(smallint), k1(int), v2(bigint). Project {v2, 
k0}: the schema has
+    // one key column (k0) but it is not the leading column, so ordinal 0 is a 
non-key column.
+    ReadSchema projected(
+            project_columns_by_ordinal(create_col_schemas(), 
std::vector<ColumnId> {2, 0}));
+    auto output_schema = std::make_shared<ReadSchema>(projected);
+
+    std::vector<RowwiseIteratorUPtr> inputs;
+    inputs.push_back(std::make_unique<ProjectedColumnsUtIterator>(projected, 
10));
+    inputs.push_back(std::make_unique<ProjectedColumnsUtIterator>(projected, 
10));
+
+    auto iter = new_merge_iterator(std::move(inputs), -1, false, false, 
nullptr, output_schema);
+    StorageReadOptions opts;
+    auto st = iter->init(opts);
+    EXPECT_FALSE(st.ok());
+    EXPECT_TRUE(st.to_string().find("compare contract violated") != 
std::string::npos)
+            << st.to_string();
+}
+
+// Same contract, a different non-leading-key layout: project {v2, k1}. 
num_key_columns() is 1
+// (k1), but position 0 is the value column v2, so the merge comparator would 
compare v2 as if it
+// were the leading key. init() must reject it.
+TEST(VGenericIteratorsTest, MergeRejectsProjectionWithoutLeadingKey) {
+    // Full tablet schema: k0(smallint), k1(int), v2(bigint); project {v2, k1}.
+    ReadSchema projected(
+            project_columns_by_ordinal(create_col_schemas(), 
std::vector<ColumnId> {2, 1}));
+    auto output_schema = std::make_shared<ReadSchema>(projected);
+
+    std::vector<RowwiseIteratorUPtr> inputs;
+    inputs.push_back(std::make_unique<ProjectedColumnsUtIterator>(projected, 
10));
+    inputs.push_back(std::make_unique<ProjectedColumnsUtIterator>(projected, 
10));
+
+    auto iter = new_merge_iterator(std::move(inputs), -1, false, false, 
nullptr, output_schema);
+    StorageReadOptions opts;
+    auto st = iter->init(opts);
+    EXPECT_FALSE(st.ok());
+    EXPECT_TRUE(st.to_string().find("compare contract violated") != 
std::string::npos)
+            << st.to_string();
+}
+
 } // namespace doris
diff --git 
a/be/test/storage/iterator/block_reader_binlog_vcollect_merge_test.cpp 
b/be/test/storage/iterator/block_reader_binlog_vcollect_merge_test.cpp
new file mode 100644
index 00000000000..62cef9f8e34
--- /dev/null
+++ b/be/test/storage/iterator/block_reader_binlog_vcollect_merge_test.cpp
@@ -0,0 +1,402 @@
+// 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.
+
+// End-to-end coverage of the full BlockReader -> VCollectIterator path for a
+// row-binlog scan that reads TWO rowsets holding the SAME user key with 
DIFFERENT
+// TSOs. Unlike block_reader_change_next_block_test.cpp (which injects a single
+// LevelIterator) and unlike a hand-built Level1Iterator, this test drives the 
REAL
+// VCollectIterator::add_child + build_heap path:
+//   * add_child() wraps each mock rowset reader in a real Level0Iterator, and
+//   * build_heap() assembles the merge Level1Iterator that orders same-key 
rows by
+//     ascending TSO (binlog_tso_col_idx -> sequence column, small_seq_first = 
true).
+//
+// The read schema is a real row-binlog schema: DUP_KEYS, which is what the FE 
actually
+// generates for the hidden row-binlog table. With DUP_KEYS, build_heap 
computes
+// _skip_same = false, so the merge keeps EVERY change event instead of 
deduplicating by
+// user key. The test verifies that the two rowsets are merged into one 
globally
+// (key, TSO)-ordered stream and that BlockReader folds it correctly:
+//   * MIN_DELTA collapses each key's consecutive same-key changes into its 
net change,
+//   * DETAIL emits every change event verbatim in ascending-TSO order,
+// covering same-key/different-TSO events spread across two rowsets without 
dropping any.
+
+#if defined(__clang__)
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wkeyword-macro"
+#endif
+#include "storage/iterator/block_reader.h"
+#include "storage/iterator/vcollect_iterator.h"
+#if defined(__clang__)
+#pragma clang diagnostic pop
+#endif
+
+#include <gtest/gtest.h>
+
+#include <memory>
+#include <string>
+#include <vector>
+
+#include "common/config.h"
+#include "common/status.h"
+#include "core/assert_cast.h"
+#include "core/block/block.h"
+#include "core/column/column_vector.h"
+#include "core/data_type/data_type_number.h"
+#include "io/fs/local_file_system.h"
+#include "storage/binlog.h"
+#include "storage/iterator/binlog_block_reader_utils.h"
+#include "storage/rowset/rowset.h"
+#include "storage/rowset/rowset_meta.h"
+#include "storage/rowset/rowset_reader.h"
+#include "storage/schema.h"
+#include "storage/tablet/tablet_schema.h"
+#include "storage/utils.h"
+
+namespace doris {
+
+using namespace ErrorCode;
+
+namespace {
+
+// Merged binlog block layout. The leading column is the primary key used both 
for the
+// merge-heap key comparison and for MIN_DELTA group boundaries:
+//   0: key                  (Int64, the only key column)
+//   1: val                  (Int64, "after" value)
+//   2: __BEFORE__val__       (Int64, "before" value mirror)
+//   3: __DORIS_BINLOG_TSO__  (Int64, the merge sequence / order column)
+//   4: __DORIS_BINLOG_LSN__  (Int64)
+//   5: __DORIS_BINLOG_OP__   (Int64, ROW_BINLOG_APPEND/UPDATE/DELETE)
+constexpr int KEY_IDX = 0;
+constexpr int VAL_IDX = 1;
+constexpr int BEFORE_VAL_IDX = 2;
+constexpr int TSO_IDX = 3;
+constexpr int LSN_IDX = 4;
+constexpr int OP_IDX = 5;
+constexpr int NUM_COLS = 6;
+
+struct Row {
+    int64_t key;
+    int64_t val;
+    int64_t before_val;
+    int64_t tso;
+    int64_t lsn;
+    int64_t op;
+};
+
+// Row-binlog read schema: single leading key column, a value column plus its 
__BEFORE__
+// mirror, and the three binlog meta columns. Marking BINLOG_TSO_COL by name 
makes
+// TabletSchema::binlog_tso_col_idx() return its position, which 
Level1Iterator::init()
+// uses to pick the TSO as the merge sequence column.
+std::shared_ptr<TabletSchema> make_binlog_schema() {
+    auto schema = std::make_shared<TabletSchema>();
+    const std::string col_names[] = {
+            "key",          "val",          
binlog::build_before_column_name("val"),
+            BINLOG_TSO_COL, BINLOG_LSN_COL, BINLOG_OP_COL};
+    for (int i = 0; i < NUM_COLS; ++i) {
+        TabletColumn col;
+        col.set_name(col_names[i]);
+        col.set_type(FieldType::OLAP_FIELD_TYPE_BIGINT);
+        col.set_unique_id(i);
+        col.set_is_key(i == KEY_IDX); // only the leading key column is a key
+        schema->append_column(std::move(col));
+    }
+    // DUP_KEYS matches the schema the FE generates for the hidden row-binlog 
table; it makes
+    // build_heap compute _skip_same = false so the merge keeps every change 
event.
+    schema->_keys_type = KeysType::DUP_KEYS;
+    return schema;
+}
+
+// Minimal RowsetMeta carrying only num_rows (used by build_heap to pick the 
base rowset).
+class FakeRowsetMeta : public RowsetMeta {
+public:
+    FakeRowsetMeta() : RowsetMeta() { _fs = io::global_local_filesystem(); }
+    io::FileSystemSPtr fs() override { return _fs; }
+
+private:
+    io::FileSystemSPtr _fs;
+};
+
+// Minimal Rowset shell: no IO, just holds the meta so 
rowset()->rowset_meta()->num_rows()
+// works and type()==BETA_ROWSET satisfies Level0Iterator's DCHECK.
+class FakeRowset : public Rowset {
+public:
+    FakeRowset(TabletSchemaSPtr schema, RowsetMetaSharedPtr meta)
+            : Rowset(schema, meta, "/fake/tablet/path") {}
+
+    Status create_reader(std::shared_ptr<RowsetReader>* result) override {
+        return Status::NotSupported("");
+    }
+    Status remove() override { return Status::OK(); }
+    Status link_files_to(const std::string&, RowsetId, size_t, 
std::set<int64_t>*) override {
+        return Status::OK();
+    }
+    Status copy_files_to(const std::string&, const RowsetId&) override { 
return Status::OK(); }
+    Status remove_old_files(std::vector<std::string>*) override { return 
Status::OK(); }
+    Status check_file_exist() override { return Status::OK(); }
+    Status upload_to(const StorageResource&, const RowsetId&) override { 
return Status::OK(); }
+    Status get_inverted_index_size(int64_t* index_size) override {
+        *index_size = 0;
+        return Status::OK();
+    }
+    void clear_inverted_index_cache() override {}
+    Status init() override { return Status::OK(); }
+    void do_close() override {}
+    Status check_current_rowset_segment() override { return Status::OK(); }
+    int64_t num_segments() const override { return 0; }
+    Result<std::string> segment_path(int64_t) override {
+        return ResultError(Status::InternalError(""));
+    }
+};
+
+// Fake per-rowset reader. next_batch(Block*) fills the caller-provided block 
(built by
+// Level0Iterator from the read schema) with the preset rows on the first 
call, then
+// reports END_OF_FILE with an empty block. This is the single-rowset stream a 
real
+// BetaRowsetReader would hand a Level0Iterator, so add_child/build_heap run 
for real.
+class FakeRowsetReader : public RowsetReader {
+public:
+    FakeRowsetReader(std::vector<Row> rows, int64_t version)
+            : _rows(std::move(rows)), _version(version) {
+        auto meta = std::make_shared<FakeRowsetMeta>();
+        meta->set_num_rows(static_cast<int64_t>(_rows.size()));
+        meta->set_rowset_type(BETA_ROWSET);
+        _rowset = std::make_shared<FakeRowset>(nullptr, meta);
+        _read_schema = 
std::make_shared<ReadSchema>(make_binlog_schema()->columns());
+    }
+
+    Status init(RowsetReaderContext*, const RowSetSplits&) override { return 
Status::OK(); }
+    Status get_segment_iterators(RowsetReaderContext*, 
std::vector<RowwiseIteratorUPtr>*,
+                                 bool) override {
+        return Status::OK();
+    }
+    void reset_read_options() override {}
+
+    Status next_batch(Block* block) override {
+        if (_emitted) {
+            return Status::Error<END_OF_FILE>("");
+        }
+        _emitted = true;
+        // The block is created from the read schema (all BIGINT, 
non-nullable) and
+        // cleared before each refresh, so append straight into its Int64 
columns. The
+        // scoped guard writes the filled columns back into `block` on 
destruction, so
+        // block->rows() reflects the appended rows (Level0Iterator relies on 
that).
+        auto columns_guard = block->mutate_columns_scoped();
+        auto& columns = columns_guard.mutable_columns();
+        auto col_of = [&](int idx) -> ColumnInt64& {
+            return assert_cast<ColumnInt64&>(*columns[idx]);
+        };
+        for (const auto& r : _rows) {
+            col_of(KEY_IDX).insert_value(r.key);
+            col_of(VAL_IDX).insert_value(r.val);
+            col_of(BEFORE_VAL_IDX).insert_value(r.before_val);
+            col_of(TSO_IDX).insert_value(r.tso);
+            col_of(LSN_IDX).insert_value(r.lsn);
+            col_of(OP_IDX).insert_value(r.op);
+        }
+        return Status::OK();
+    }
+    Status next_batch(BlockView*) override { return Status::NotSupported(""); }
+    Status next_batch(BlockWithSameBit*) override { return 
Status::NotSupported(""); }
+
+    bool delete_flag() override { return false; }
+    Version version() override { return Version(_version, _version); }
+    RowsetSharedPtr rowset() override { return _rowset; }
+    const ReadSchema& read_schema() const override { return *_read_schema; }
+    int64_t filtered_rows() override { return 0; }
+    uint64_t merged_rows() override { return 0; }
+    RowsetTypePB type() const override { return BETA_ROWSET; }
+    int64_t newest_write_timestamp() override { return 0; }
+    void update_profile(RuntimeProfile*) override {}
+    RowsetReaderSharedPtr clone() override {
+        return std::make_shared<FakeRowsetReader>(_rows, _version);
+    }
+    void set_topn_limit(size_t) override {}
+
+private:
+    std::vector<Row> _rows;
+    int64_t _version;
+    bool _emitted = false;
+    RowsetSharedPtr _rowset;
+    ReadSchemaSPtr _read_schema;
+};
+
+// Wire a BlockReader as if init() had completed for a row-binlog 
MIN_DELTA/DETAIL scan,
+// then drive the REAL VCollectIterator init + add_child + build_heap path 
with one
+// child per rowset. Nothing here hardcodes _skip_same; build_heap computes it 
from the
+// (DUP_KEYS) row-binlog read schema, which yields _skip_same = false so every 
event is
+// kept.
+void configure_two_rowset_merge(BlockReader& reader, std::vector<Row> 
rowset0_rows,
+                                std::vector<Row> rowset1_rows, size_t 
batch_size) {
+    config::enable_adaptive_batch_size = false;
+    reader._reader_context.batch_size = batch_size;
+    reader._reader_context.read_row_binlog = true;
+    reader._reader_context.read_orderby_key_columns = nullptr;
+    // Non-QUERY reader type keeps VCollectIterator::init() from dereferencing 
the (unset)
+    // _tablet while deciding _merge; the row-binlog branch then forces the 
merge anyway.
+    reader._reader_type = ReaderType::READER_BASE_COMPACTION;
+
+    reader._tablet_schema = make_binlog_schema();
+    // Identity read schema over the full row-binlog layout. ReadSchema 
derives num_key_columns()
+    // from is_key() and tso_ordinal() from the BINLOG_TSO_COL name, which 
Level1Iterator::init()
+    // uses to pick the TSO as the merge sequence column, and which
+    // _validate_merge_compare_contract() checks for the leading key prefix.
+    reader._read_schema = 
std::make_shared<ReadSchema>(reader._tablet_schema->columns());
+
+    // Mirror BlockReader::_init_collect_iter: init the collect iterator 
(force_merge=true,
+    // as a MIN_DELTA stream does), add one child per rowset, then build the 
heap.
+    reader._vcollect_iter.init(&reader, /*ori_data_overlapping=*/true, 
/*force_merge=*/true,
+                               /*is_reverse=*/false);
+
+    std::vector<RowsetReaderSharedPtr> rs_readers;
+    // Give rowset0 more rows so build_heap picks a deterministic base rowset; 
behavior is
+    // symmetric, this just fixes the base/cumulative split for the two-child 
path.
+    auto reader0 = std::make_shared<FakeRowsetReader>(std::move(rowset0_rows), 
/*version=*/2);
+    auto reader1 = std::make_shared<FakeRowsetReader>(std::move(rowset1_rows), 
/*version=*/3);
+    for (auto& rs_reader : {reader0, reader1}) {
+        RowSetSplits split(rs_reader);
+        ASSERT_TRUE(reader._vcollect_iter.add_child(split).ok());
+        rs_readers.push_back(rs_reader);
+    }
+    ASSERT_TRUE(reader._vcollect_iter.build_heap(rs_readers).ok());
+
+    auto st = reader._vcollect_iter.current_row(&reader._next_row);
+    reader._eof = st.is<END_OF_FILE>();
+}
+
+Block make_output_block() {
+    Block block;
+    auto type = std::make_shared<DataTypeInt64>();
+    block.insert({ColumnInt64::create(), type, "key"});
+    block.insert({ColumnInt64::create(), type, "val"});
+    block.insert({ColumnInt64::create(), type, 
binlog::build_before_column_name("val")});
+    block.insert({ColumnInt64::create(), type, BINLOG_TSO_COL});
+    block.insert({ColumnInt64::create(), type, BINLOG_LSN_COL});
+    block.insert({ColumnInt64::create(), type, BINLOG_OP_COL});
+    return block;
+}
+
+int64_t out_i64(const Block& block, int col, int row) {
+    return assert_cast<const 
ColumnInt64&>(*block.get_by_position(col).column).get_element(row);
+}
+
+struct OutRow {
+    int64_t key;
+    int64_t val;
+    int64_t op;
+};
+
+std::vector<OutRow> drain(BlockReader& reader, Status 
(BlockReader::*fn)(Block*, bool*)) {
+    std::vector<OutRow> result;
+    bool eof = false;
+    int guard = 0;
+    while (!eof) {
+        Block block = make_output_block();
+        Status st = (reader.*fn)(&block, &eof);
+        EXPECT_TRUE(st.ok()) << st;
+        for (size_t r = 0; r < block.rows(); ++r) {
+            result.push_back({out_i64(block, KEY_IDX, r), out_i64(block, 
VAL_IDX, r),
+                              out_i64(block, OP_IDX, r)});
+        }
+        if (++guard >= 1000) {
+            ADD_FAILURE() << "drain did not terminate";
+            break;
+        }
+    }
+    return result;
+}
+
+} // namespace
+
+class BlockReaderBinlogVCollectMergeTest : public testing::Test {
+protected:
+    void SetUp() override { _saved_adaptive = 
config::enable_adaptive_batch_size; }
+    void TearDown() override { config::enable_adaptive_batch_size = 
_saved_adaptive; }
+    bool _saved_adaptive = false;
+};
+
+// Two rowsets, same key, different TSO. rowset0 holds the APPEND (tso=1), 
rowset1 holds
+// a later UPDATE (tso=2). With the DUP_KEYS row-binlog schema, build_heap 
keeps every
+// same-key row: the merge orders them by ascending TSO into one key group, 
and MIN_DELTA
+// folds APPEND+UPDATE into a single INSERT carrying the most recent value.
+TEST_F(BlockReaderBinlogVCollectMergeTest, 
MinDeltaSameKeyAcrossRowsetsFoldsToInsert) {
+    BlockReader reader;
+    configure_two_rowset_merge(
+            reader,
+            {{/*key=*/1, /*val=*/10, /*before=*/0, /*tso=*/1, /*lsn=*/1, 
ROW_BINLOG_APPEND}},
+            {{/*key=*/1, /*val=*/20, /*before=*/10, /*tso=*/2, /*lsn=*/2, 
ROW_BINLOG_UPDATE}},
+            /*batch_size=*/16);
+
+    auto out = drain(reader, &BlockReader::_min_delta_next_block);
+    ASSERT_EQ(out.size(), 1);
+    EXPECT_EQ(out[0].op, binlog::STREAM_CHANGE_INSERT);
+    EXPECT_EQ(out[0].key, 1);
+    EXPECT_EQ(out[0].val, 20); // most recent value, i.e. rowset1's later TSO
+}
+
+// Same key across two rowsets where the later rowset deletes it: APPEND 
(tso=1) then
+// DELETE (tso=3) folds to SKIP, so the merged group produces no output. This 
only holds
+// if both rows land in one key group; if the merge dropped one, the surviving 
lone event
+// would fold to a stray INSERT or DELETE instead of cancelling out.
+TEST_F(BlockReaderBinlogVCollectMergeTest, 
MinDeltaSameKeyAcrossRowsetsFoldsToSkip) {
+    BlockReader reader;
+    configure_two_rowset_merge(reader, {{5, 100, 0, 1, 1, ROW_BINLOG_APPEND}},
+                               {{5, 100, 100, 3, 3, ROW_BINLOG_DELETE}}, 16);
+
+    auto out = drain(reader, &BlockReader::_min_delta_next_block);
+    EXPECT_TRUE(out.empty());
+}
+
+// Interleaved keys across two rowsets. Each rowset is independently 
key-ordered, and each
+// key's change chain is spread over both rowsets under different TSOs:
+//   key 1: APPEND(tso=1, rowset0) + UPDATE(tso=3, rowset1) -> INSERT(val=15)
+//   key 2: UPDATE(tso=2, rowset0) + DELETE(tso=4, rowset1) -> 
DELETE(before=18)
+// The merge must produce the globally (key, TSO)-ordered stream and keep 
every event so
+// MIN_DELTA groups each key across the rowset boundary.
+TEST_F(BlockReaderBinlogVCollectMergeTest, 
MinDeltaInterleavedKeysAcrossRowsets) {
+    BlockReader reader;
+    configure_two_rowset_merge(
+            reader, {{1, 10, 0, 1, 1, ROW_BINLOG_APPEND}, {2, 20, 18, 2, 2, 
ROW_BINLOG_UPDATE}},
+            {{1, 15, 10, 3, 3, ROW_BINLOG_UPDATE}, {2, 25, 20, 4, 4, 
ROW_BINLOG_DELETE}}, 16);
+
+    auto out = drain(reader, &BlockReader::_min_delta_next_block);
+    ASSERT_EQ(out.size(), 2);
+    EXPECT_EQ(out[0].key, 1);
+    EXPECT_EQ(out[0].op, binlog::STREAM_CHANGE_INSERT);
+    EXPECT_EQ(out[0].val, 15); // most recent value for key 1
+    EXPECT_EQ(out[1].key, 2);
+    EXPECT_EQ(out[1].op, binlog::STREAM_CHANGE_DELETE);
+    EXPECT_EQ(out[1].val, 18); // delete uses the first op's __BEFORE__ value
+}
+
+// DETAIL scan over the same two-rowset / same-key / different-TSO input emits 
every event
+// verbatim in ascending-TSO order: the rowset0 APPEND becomes an INSERT and 
the rowset1
+// UPDATE becomes a BEFORE + AFTER pair. A dedup in build_heap would drop the 
APPEND (or
+// the UPDATE), leaving history incomplete.
+TEST_F(BlockReaderBinlogVCollectMergeTest, 
DetailSameKeyAcrossRowsetsOrderedByTso) {
+    BlockReader reader;
+    configure_two_rowset_merge(reader, {{1, 10, 0, 1, 1, ROW_BINLOG_APPEND}},
+                               {{1, 20, 10, 2, 2, ROW_BINLOG_UPDATE}}, 16);
+
+    auto out = drain(reader, &BlockReader::_detail_change_next_block);
+    ASSERT_EQ(out.size(), 3);
+    EXPECT_EQ(out[0].op, binlog::STREAM_CHANGE_INSERT);
+    EXPECT_EQ(out[0].val, 10); // rowset0 APPEND (tso=1) first
+    EXPECT_EQ(out[1].op, binlog::STREAM_CHANGE_UPDATE_BEFORE);
+    EXPECT_EQ(out[1].val, 10); // rowset1 UPDATE (tso=2) before value
+    EXPECT_EQ(out[2].op, binlog::STREAM_CHANGE_UPDATE_AFTER);
+    EXPECT_EQ(out[2].val, 20); // rowset1 UPDATE after value
+}
+
+} // namespace doris
diff --git a/be/test/storage/iterator/block_reader_min_delta_test.cpp 
b/be/test/storage/iterator/block_reader_min_delta_test.cpp
index 54129b4190e..0c65b31cbb8 100644
--- a/be/test/storage/iterator/block_reader_min_delta_test.cpp
+++ b/be/test/storage/iterator/block_reader_min_delta_test.cpp
@@ -136,4 +136,104 @@ TEST_F(BlockReaderMinDeltaTest, 
RowBinlogOperationCodeLayoutGuard) {
     EXPECT_EQ(2, ROW_BINLOG_DELETE);
 }
 
+// Contract test for the group-boundary rule in 
BlockReader::_min_delta_next_block. A MIN_DELTA
+// group is a run of consecutive rows sharing the same user key; the reader 
folds each group into
+// one net change via calculate_result(first_op, last_op). Exercising 
_min_delta_next_block end to
+// end needs a fully constructed BlockReader + VCollectIterator + rowset 
readers, which is far too
+// heavy for a unit test, so we pin the boundary rule itself.
+//
+// The subtle bug this guards against: the reader used to split groups on 
IteratorRowRef::is_same,
+// which the segment merge sets only for CROSS-segment same-key matches (it 
drives dedup). When a
+// compaction / row-binlog LMax quick-merge folds a key's whole change chain 
into ONE segment, those
+// consecutive same-key rows carry is_same = false, so an is_same-based 
grouping shatters one key
+// into many single-row groups. The fix groups by comparing the leading key 
columns directly.
+namespace {
+struct QmRow {
+    int64_t key;
+    int64_t op;
+    bool is_same; // as produced by the segment merge (cross-segment dedup 
marker)
+};
+
+// Old behavior: start a new group whenever is_same is false.
+std::vector<ResultType> fold_by_is_same(const std::vector<QmRow>& rows) {
+    std::vector<ResultType> out;
+    size_t i = 0;
+    while (i < rows.size()) {
+        int64_t first_op = rows[i].op;
+        int64_t last_op = rows[i].op;
+        size_t j = i + 1;
+        while (j < rows.size() && rows[j].is_same) {
+            last_op = rows[j].op;
+            ++j;
+        }
+        
out.push_back(binlog::AggregateFunctionMinDelta::calculate_result(first_op, 
last_op));
+        i = j;
+    }
+    return out;
+}
+
+// New behavior: start a new group when the user key changes (input is 
globally key-ordered).
+std::vector<ResultType> fold_by_key(const std::vector<QmRow>& rows) {
+    std::vector<ResultType> out;
+    size_t i = 0;
+    while (i < rows.size()) {
+        int64_t first_op = rows[i].op;
+        int64_t last_op = rows[i].op;
+        size_t j = i + 1;
+        while (j < rows.size() && rows[j].key == rows[i].key) {
+            last_op = rows[j].op;
+            ++j;
+        }
+        
out.push_back(binlog::AggregateFunctionMinDelta::calculate_result(first_op, 
last_op));
+        i = j;
+    }
+    return out;
+}
+} // namespace
+
+TEST_F(BlockReaderMinDeltaTest, GroupBoundaryUsesKeyNotIsSame) {
+    // Three keys, each with a whole change chain folded into a single 
quick-merge segment, so every
+    // row's is_same is false (no cross-segment match). Rows are globally 
key-ordered by TSO.
+    //   key 1: APPEND, UPDATE, UPDATE  -> folds to INSERT
+    //   key 2: UPDATE, UPDATE          -> folds to UPDATE_BEFORE_AFTER
+    //   key 3: APPEND, DELETE          -> folds to SKIP
+    const std::vector<QmRow> rows = {
+            {1, ROW_BINLOG_APPEND, false}, {1, ROW_BINLOG_UPDATE, false},
+            {1, ROW_BINLOG_UPDATE, false}, {2, ROW_BINLOG_UPDATE, false},
+            {2, ROW_BINLOG_UPDATE, false}, {3, ROW_BINLOG_APPEND, false},
+            {3, ROW_BINLOG_DELETE, false},
+    };
+
+    // Grouping by key yields exactly one folded change per key.
+    const std::vector<ResultType> by_key = fold_by_key(rows);
+    ASSERT_EQ(3u, by_key.size());
+    EXPECT_EQ(ResultType::INSERT, by_key[0]);
+    EXPECT_EQ(ResultType::UPDATE_BEFORE_AFTER, by_key[1]);
+    EXPECT_EQ(ResultType::SKIP, by_key[2]);
+
+    // The old is_same-based grouping shatters each key into single-row 
groups: 7 rows -> 7 groups,
+    // none of which reflect the true per-key net change (each APPEND/UPDATE 
alone folds to INSERT/
+    // UPDATE, and the key-3 APPEND+DELETE that should cancel to SKIP is 
instead two separate rows).
+    const std::vector<ResultType> by_is_same = fold_by_is_same(rows);
+    EXPECT_EQ(7u, by_is_same.size());
+    EXPECT_NE(by_key.size(), by_is_same.size());
+}
+
+TEST_F(BlockReaderMinDeltaTest, GroupBoundaryMixedIsSameStillGroupsByKey) {
+    // Realistic mix: some same-key rows are cross-segment (is_same=true), 
others were folded into one
+    // segment (is_same=false). Grouping by key must be insensitive to how 
is_same happened to be set.
+    //   key 1: APPEND(false), UPDATE(true), UPDATE(false)  -> INSERT
+    //   key 2: UPDATE(false), DELETE(false)                -> DELETE
+    const std::vector<QmRow> rows = {
+            {1, ROW_BINLOG_APPEND, false}, {1, ROW_BINLOG_UPDATE, true},
+            {1, ROW_BINLOG_UPDATE, false}, {2, ROW_BINLOG_UPDATE, false},
+            {2, ROW_BINLOG_DELETE, false},
+    };
+
+    const std::vector<ResultType> by_key = fold_by_key(rows);
+    ASSERT_EQ(2u, by_key.size());
+    EXPECT_EQ(ResultType::INSERT, by_key[0]);
+    EXPECT_EQ(ResultType::DELETE, by_key[1]);
+}
+
 } // namespace doris
diff --git a/be/test/storage/row_binlog_vmerge_compaction_test.cpp 
b/be/test/storage/row_binlog_vmerge_compaction_test.cpp
new file mode 100644
index 00000000000..62bdd43963d
--- /dev/null
+++ b/be/test/storage/row_binlog_vmerge_compaction_test.cpp
@@ -0,0 +1,400 @@
+// 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.
+
+// Producer-side coverage for horizontal (non-vertical) row-binlog compaction.
+//
+// With enable_vertical_compaction=false a row-binlog cumulative compaction 
that takes the
+// real merge path runs Merger::vmerge_rowsets. Two things must hold for 
downstream
+// MIN_DELTA reads to be correct:
+//   1. The merged output must be globally (key, TSO)-ordered, not a UNION of 
the inputs.
+//      A UNION would write [email protected]@TSO1 then [email protected]@TSO2, so 
a key's
+//      change chain is no longer consecutive.
+//   2. When a forced segment boundary splits a key range across output 
segments (the same
+//      user key lands in more than one segment), the output rowset meta must 
be OVERLAPPING
+//      so BetaRowsetReader::is_merge_iterator() picks a merge iterator.
+//
+// This test drives the real Merger::vmerge_rowsets on a row-binlog tablet 
with two input
+// rowsets that share every key under different TSOs, forces a multi-segment 
output via
+// RowsetWriterContext::max_rows_per_segment, and asserts both properties. A 
negative case
+// on a plain (non-binlog) DUP_KEYS tablet proves the fix is gated on the 
row-binlog path.
+
+#include <gen_cpp/olap_common.pb.h>
+#include <gen_cpp/olap_file.pb.h>
+#include <gtest/gtest.h>
+#include <unistd.h>
+
+#include <memory>
+#include <string>
+#include <tuple>
+#include <unordered_map>
+#include <vector>
+
+#include "common/status.h"
+#include "core/block/block.h"
+#include "io/fs/local_file_system.h"
+#include "runtime/exec_env.h"
+#include "storage/merger.h"
+#include "storage/olap_common.h"
+#include "storage/rowset/beta_rowset.h"
+#include "storage/rowset/rowset.h"
+#include "storage/rowset/rowset_factory.h"
+#include "storage/rowset/rowset_meta.h"
+#include "storage/rowset/rowset_reader.h"
+#include "storage/rowset/rowset_reader_context.h"
+#include "storage/rowset/rowset_writer.h"
+#include "storage/rowset/rowset_writer_context.h"
+#include "storage/schema.h"
+#include "storage/storage_engine.h"
+#include "storage/tablet/tablet.h"
+#include "storage/tablet/tablet_meta.h"
+#include "storage/tablet/tablet_schema.h"
+#include "storage/utils.h"
+#include "util/uid_util.h"
+
+namespace doris {
+using namespace ErrorCode;
+
+namespace {
+constexpr uint32_t kMaxPathLen = 1024;
+constexpr char kTestDir[] = "/row_binlog_vmerge_test";
+} // namespace
+
+class RowBinlogVmergeCompactionTest : public testing::Test {
+protected:
+    void SetUp() override {
+        char buffer[kMaxPathLen];
+        EXPECT_NE(getcwd(buffer, kMaxPathLen), nullptr);
+        _absolute_dir = std::string(buffer) + kTestDir;
+        auto st = 
io::global_local_filesystem()->delete_directory(_absolute_dir);
+        ASSERT_TRUE(st.ok()) << st;
+        st = io::global_local_filesystem()->create_directory(_absolute_dir);
+        ASSERT_TRUE(st.ok()) << st;
+        EXPECT_TRUE(io::global_local_filesystem()
+                            ->create_directory(_absolute_dir + "/tablet_path")
+                            .ok());
+        doris::EngineOptions options;
+        auto engine = std::make_unique<StorageEngine>(options);
+        _engine = engine.get();
+        ExecEnv::GetInstance()->set_storage_engine(std::move(engine));
+    }
+
+    void TearDown() override {
+        
EXPECT_TRUE(io::global_local_filesystem()->delete_directory(_absolute_dir).ok());
+        _engine = nullptr;
+        ExecEnv::GetInstance()->set_storage_engine(nullptr);
+    }
+
+    // Row-binlog read schema: single leading key column, a value column, and 
the binlog TSO
+    // column (marked via binlog_tso_col_idx so Level1Iterator uses it as the 
merge sequence
+    // column). DUP_KEYS matches the FE-generated hidden row-binlog table, 
which keeps every
+    // change event instead of deduplicating by user key.
+    TabletSchemaSPtr create_row_binlog_schema() {
+        TabletSchemaPB pb;
+        pb.set_keys_type(DUP_KEYS);
+        pb.set_num_short_key_columns(1);
+        pb.set_num_rows_per_row_block(1024);
+        pb.set_compress_kind(COMPRESS_NONE);
+        pb.set_next_column_unique_id(4);
+
+        ColumnPB* key = pb.add_column();
+        key->set_unique_id(0);
+        key->set_name("k");
+        key->set_type("INT");
+        key->set_is_key(true);
+        key->set_length(4);
+        key->set_index_length(4);
+        key->set_is_nullable(false);
+
+        ColumnPB* val = pb.add_column();
+        val->set_unique_id(1);
+        val->set_name("v");
+        val->set_type("INT");
+        val->set_is_key(false);
+        val->set_length(4);
+        val->set_is_nullable(false);
+
+        // The binlog TSO column must be BIGINT; 
SegmentIterator::_update_tso_col_if_needed
+        // asserts OLAP_FIELD_TYPE_BIGINT for it.
+        ColumnPB* tso = pb.add_column();
+        tso->set_unique_id(2);
+        tso->set_name(BINLOG_TSO_COL);
+        tso->set_type("BIGINT");
+        tso->set_is_key(false);
+        tso->set_length(8);
+        tso->set_is_nullable(false);
+        tso->set_visible(false);
+        pb.set_binlog_tso_col_idx(2);
+
+        auto schema = std::make_shared<TabletSchema>();
+        schema->init_from_pb(pb);
+        return schema;
+    }
+
+    TabletSchemaSPtr create_plain_dup_schema() {
+        TabletSchemaPB pb;
+        pb.set_keys_type(DUP_KEYS);
+        pb.set_num_short_key_columns(1);
+        pb.set_num_rows_per_row_block(1024);
+        pb.set_compress_kind(COMPRESS_NONE);
+        pb.set_next_column_unique_id(3);
+
+        ColumnPB* key = pb.add_column();
+        key->set_unique_id(0);
+        key->set_name("k");
+        key->set_type("INT");
+        key->set_is_key(true);
+        key->set_length(4);
+        key->set_index_length(4);
+        key->set_is_nullable(false);
+
+        ColumnPB* val = pb.add_column();
+        val->set_unique_id(1);
+        val->set_name("v");
+        val->set_type("INT");
+        val->set_is_key(false);
+        val->set_length(4);
+        val->set_is_nullable(false);
+
+        auto schema = std::make_shared<TabletSchema>();
+        schema->init_from_pb(pb);
+        return schema;
+    }
+
+    RowsetWriterContext create_rowset_writer_context(const TabletSchemaSPtr& 
tablet_schema,
+                                                     const SegmentsOverlapPB& 
overlap,
+                                                     uint32_t 
max_rows_per_segment, Version version,
+                                                     bool enable_binlog) {
+        static int64_t inc_id = 0;
+        RowsetWriterContext context;
+        RowsetId rowset_id;
+        rowset_id.init(++inc_id);
+        context.rowset_id = rowset_id;
+        context.rowset_type = BETA_ROWSET;
+        context.rowset_state = VISIBLE;
+        context.tablet_schema = tablet_schema;
+        context.tablet_path = _absolute_dir + "/tablet_path";
+        context.version = version;
+        context.segments_overlap = overlap;
+        context.max_rows_per_segment = max_rows_per_segment;
+        if (enable_binlog) {
+            context.write_binlog_opt().enable = true;
+        }
+        return context;
+    }
+
+    // Build a single-segment input rowset. Each (key, val, tso) tuple becomes 
one row; every
+    // input rowset shares the same keys but a distinct tso so the two rowsets 
overlap on keys.
+    RowsetSharedPtr create_input_rowset(const TabletSchemaSPtr& tablet_schema, 
bool with_tso,
+                                        const std::vector<std::tuple<int, int, 
int>>& rows,
+                                        int64_t version) {
+        auto context = create_rowset_writer_context(tablet_schema, 
NONOVERLAPPING, UINT32_MAX,
+                                                    {version, version}, 
/*enable_binlog=*/false);
+        auto res = RowsetFactory::create_rowset_writer(*_engine, context, 
false);
+        EXPECT_TRUE(res.has_value()) << res.error();
+        auto writer = std::move(res).value();
+
+        Block block = tablet_schema->create_storage_block();
+        auto columns = std::move(block).mutate_columns();
+        for (const auto& [k, v, tso] : rows) {
+            columns[0]->insert_data((const char*)&k, sizeof(k));
+            columns[1]->insert_data((const char*)&v, sizeof(v));
+            if (with_tso) {
+                int64_t tso64 = tso;
+                columns[2]->insert_data((const char*)&tso64, sizeof(tso64));
+            }
+        }
+        block.set_columns(std::move(columns));
+        EXPECT_TRUE(writer->add_block(&block).ok());
+        EXPECT_TRUE(writer->flush().ok());
+
+        RowsetSharedPtr rowset;
+        EXPECT_EQ(Status::OK(), writer->build(rowset));
+        return rowset;
+    }
+
+    TabletSharedPtr create_tablet(const TabletSchema& tablet_schema, bool 
row_binlog_role) {
+        std::vector<TColumn> cols;
+        std::unordered_map<uint32_t, uint32_t> col_ordinal_to_unique_id;
+        for (auto i = 0; i < tablet_schema.num_columns(); i++) {
+            const TabletColumn& column = tablet_schema.column(i);
+            TColumn col;
+            col.column_type.type = TPrimitiveType::INT;
+            col.__set_column_name(column.name());
+            col.__set_is_key(column.is_key());
+            cols.push_back(col);
+            col_ordinal_to_unique_id[i] = column.unique_id();
+        }
+
+        TTabletSchema t_tablet_schema;
+        
t_tablet_schema.__set_short_key_column_count(tablet_schema.num_short_key_columns());
+        t_tablet_schema.__set_schema_hash(3333);
+        t_tablet_schema.__set_keys_type(TKeysType::DUP_KEYS);
+        t_tablet_schema.__set_storage_type(TStorageType::COLUMN);
+        t_tablet_schema.__set_columns(cols);
+        TabletMetaSharedPtr tablet_meta(new TabletMeta(
+                1, 1, 1, 1, 1, 1, t_tablet_schema, 1, 
col_ordinal_to_unique_id, UniqueId(1, 2),
+                TTabletType::TABLET_TYPE_DISK, TCompressionType::LZ4F, 0, 
false));
+        if (row_binlog_role) {
+            tablet_meta->set_tablet_role(TabletRolePB::TABLET_ROLE_ROW_BINLOG);
+        }
+
+        TabletSharedPtr tablet(new Tablet(*_engine, tablet_meta, nullptr));
+        static_cast<void>(tablet->init());
+        return tablet;
+    }
+
+    // Read the output rowset in physical (segment) order. 
need_ordered_result=false makes the
+    // reader concatenate segments as written, so the returned sequence 
reflects the on-disk
+    // layout produced by the merge.
+    std::vector<std::tuple<int, int, int>> read_all(const RowsetSharedPtr& 
rowset,
+                                                    const TabletSchemaSPtr& 
tablet_schema,
+                                                    bool with_tso) {
+        std::vector<ColumnId> ordinals;
+        for (uint32_t i = 0; i < tablet_schema->num_columns(); ++i) {
+            ordinals.push_back(i);
+        }
+        auto read_schema = std::make_shared<ReadSchema>(
+                project_columns_by_ordinal(tablet_schema->columns(), 
ordinals));
+
+        RowsetReaderContext reader_context;
+        reader_context.tablet_schema = tablet_schema;
+        reader_context.need_ordered_result = false;
+        reader_context.read_schema = read_schema;
+
+        RowsetReaderSharedPtr reader;
+        EXPECT_TRUE(rowset->create_reader(&reader).ok());
+        EXPECT_TRUE(reader->init(&reader_context).ok());
+
+        std::vector<std::tuple<int, int, int>> out;
+        Status s;
+        do {
+            Block block = read_schema->create_read_block();
+            s = reader->next_batch(&block);
+            auto columns = block.get_columns_with_type_and_name();
+            for (auto i = 0; i < block.rows(); i++) {
+                int tso = with_tso ? 
static_cast<int>(columns[2].column->get_int(i)) : 0;
+                
out.emplace_back(static_cast<int>(columns[0].column->get_int(i)),
+                                 
static_cast<int>(columns[1].column->get_int(i)), tso);
+            }
+        } while (s.ok());
+        EXPECT_TRUE(s.is<END_OF_FILE>()) << s;
+        return out;
+    }
+
+    std::string _absolute_dir;
+    StorageEngine* _engine = nullptr;
+};
+
+// A real (key, TSO) merge of two row-binlog rowsets that share every key must 
interleave the
+// events by key then TSO, and a forced segment boundary that splits a key 
range must mark the
+// output OVERLAPPING.
+TEST_F(RowBinlogVmergeCompactionTest, 
HorizontalMergeIsKeyTsoOrderedAndOverlapping) {
+    TabletSchemaSPtr schema = create_row_binlog_schema();
+    TabletSharedPtr tablet = create_tablet(*schema, /*row_binlog_role=*/true);
+    ASSERT_TRUE(tablet->is_row_binlog_tablet());
+
+    // rs0: keys 1..3 at tso=10; rs1: keys 1..3 at tso=20. The two rowsets 
overlap on keys.
+    auto rs0 = create_input_rowset(schema, /*with_tso=*/true,
+                                   {{1, 100, 10}, {2, 200, 10}, {3, 300, 10}}, 
/*version=*/1);
+    auto rs1 = create_input_rowset(schema, /*with_tso=*/true,
+                                   {{1, 110, 20}, {2, 210, 20}, {3, 310, 20}}, 
/*version=*/2);
+
+    // Output default is NONOVERLAPPING (matches compaction.cpp for the 
non-quick-merge path).
+    // With the (key, TSO) merge the physical order is
+    //   (1,100),(1,110),(2,200),(2,210),(3,300),(3,310)
+    // so max_rows_per_segment=3 splits key 2 across two segments (seg0 
max_key=2, seg1
+    // min_key=2), making the segments genuinely overlap.
+    auto ctx = create_rowset_writer_context(schema, NONOVERLAPPING, 
/*max_rows_per_segment=*/3,
+                                            {0, rs1->end_version()}, 
/*enable_binlog=*/true);
+    auto res = RowsetFactory::create_rowset_writer(*_engine, ctx, 
/*is_vertical=*/false);
+    ASSERT_TRUE(res.has_value()) << res.error();
+    auto writer = std::move(res).value();
+
+    std::vector<RowsetReaderSharedPtr> input_rs_readers;
+    for (auto& rowset : {rs0, rs1}) {
+        RowsetReaderSharedPtr rs_reader;
+        ASSERT_TRUE(rowset->create_reader(&rs_reader).ok());
+        input_rs_readers.push_back(std::move(rs_reader));
+    }
+
+    Merger::Statistics stats;
+    ASSERT_TRUE(Merger::vmerge_rowsets(tablet, 
ReaderType::READER_CUMULATIVE_COMPACTION, *schema,
+                                       input_rs_readers, writer.get(), &stats)
+                        .ok());
+    RowsetSharedPtr out_rowset;
+    ASSERT_EQ(Status::OK(), writer->build(out_rowset));
+
+    // Fix (B): a multi-segment output whose segments share a boundary key is 
OVERLAPPING.
+    EXPECT_GT(out_rowset->rowset_meta()->num_segments(), 1);
+    EXPECT_EQ(OVERLAPPING, out_rowset->rowset_meta()->segments_overlap());
+    EXPECT_TRUE(out_rowset->rowset_meta()->is_segments_overlapping());
+
+    // Fix (A): the merged stream is globally (key, TSO)-ordered, so each 
key's two events are
+    // consecutive with the earlier-TSO (lower version) row first. A buggy 
UNION would instead
+    // produce (1,100),(2,200),(3,300),(1,110),(2,210),(3,310). The binlog TSO 
column itself is
+    // rewritten to the rowset commit TSO on read (_update_tso_col_if_needed), 
so we assert on
+    // (key, val) which carries the per-event identity.
+    auto rows = read_all(out_rowset, schema, /*with_tso=*/false);
+    std::vector<std::pair<int, int>> got;
+    for (const auto& [k, v, tso] : rows) {
+        got.emplace_back(k, v);
+    }
+    std::vector<std::pair<int, int>> expected = {{1, 100}, {1, 110}, {2, 200},
+                                                 {2, 210}, {3, 300}, {3, 310}};
+    EXPECT_EQ(expected, got);
+}
+
+// Guard: the same overlapping-input + forced-boundary layout on a plain (non 
row-binlog)
+// DUP_KEYS tablet must NOT be marked OVERLAPPING, proving both fixes are 
gated on the
+// row-binlog path.
+TEST_F(RowBinlogVmergeCompactionTest, PlainDupMergeStaysNonOverlapping) {
+    TabletSchemaSPtr schema = create_plain_dup_schema();
+    TabletSharedPtr tablet = create_tablet(*schema, /*row_binlog_role=*/false);
+    ASSERT_FALSE(tablet->is_row_binlog_tablet());
+
+    auto rs0 =
+            create_input_rowset(schema, /*with_tso=*/false, {{1, 100, 0}, {2, 
200, 0}, {3, 300, 0}},
+                                /*version=*/1);
+    auto rs1 =
+            create_input_rowset(schema, /*with_tso=*/false, {{1, 110, 0}, {2, 
210, 0}, {3, 310, 0}},
+                                /*version=*/2);
+
+    auto ctx = create_rowset_writer_context(schema, NONOVERLAPPING, 
/*max_rows_per_segment=*/2,
+                                            {0, rs1->end_version()}, 
/*enable_binlog=*/false);
+    auto res = RowsetFactory::create_rowset_writer(*_engine, ctx, 
/*is_vertical=*/false);
+    ASSERT_TRUE(res.has_value()) << res.error();
+    auto writer = std::move(res).value();
+
+    std::vector<RowsetReaderSharedPtr> input_rs_readers;
+    for (auto& rowset : {rs0, rs1}) {
+        RowsetReaderSharedPtr rs_reader;
+        ASSERT_TRUE(rowset->create_reader(&rs_reader).ok());
+        input_rs_readers.push_back(std::move(rs_reader));
+    }
+
+    Merger::Statistics stats;
+    ASSERT_TRUE(Merger::vmerge_rowsets(tablet, 
ReaderType::READER_CUMULATIVE_COMPACTION, *schema,
+                                       input_rs_readers, writer.get(), &stats)
+                        .ok());
+    RowsetSharedPtr out_rowset;
+    ASSERT_EQ(Status::OK(), writer->build(out_rowset));
+
+    EXPECT_NE(OVERLAPPING, out_rowset->rowset_meta()->segments_overlap());
+    EXPECT_FALSE(out_rowset->rowset_meta()->is_segments_overlapping());
+}
+
+} // namespace doris
diff --git a/be/test/storage/rowset/rowset_meta_test.cpp 
b/be/test/storage/rowset/rowset_meta_test.cpp
index 954b8e9ed22..f73ce1b9c3f 100644
--- a/be/test/storage/rowset/rowset_meta_test.cpp
+++ b/be/test/storage/rowset/rowset_meta_test.cpp
@@ -455,4 +455,58 @@ TEST_F(RowsetMetaTest, 
TestSegmentsKeyBoundsAggregationTruncation) {
     EXPECT_TRUE(rs_meta.is_segments_key_bounds_truncated());
 }
 
+// is_segments_overlapping() is decided by segment count, the segments_overlap 
flag,
+// and whether the rowset is a singleton delta (start_version == end_version) 
or a
+// row-binlog rowset. For a singleton delta the ambiguous OVERLAP_UNKNOWN flag 
is still
+// treated as overlapping (a freshly ingested delta may have overlapping 
segments only
+// tagged OVERLAP_UNKNOWN). Row-binlog LMax quick merge produces non-singleton 
rowsets
+// whose segments overlap, but it writes segments_overlap = OVERLAPPING 
explicitly, so
+// it is recognized only via the explicit-OVERLAPPING branch; a row-binlog 
rowset left
+// as OVERLAP_UNKNOWN is NOT treated as overlapping. A plain non-singleton 
rowset (e.g. a
+// compaction output) keeps the original semantics and stays non-overlapping 
regardless
+// of the flag, so that old metadata written before the flag was always set 
does not
+// inflate compaction score / merge ways or degrade ordered reads after an 
upgrade.
+TEST_F(RowsetMetaTest, TestIsSegmentsOverlapping) {
+    auto check = [](int64_t num_segments, SegmentsOverlapPB overlap, int64_t 
start_version,
+                    int64_t end_version, bool is_row_binlog, bool expected) {
+        RowsetMeta rs_meta;
+        rs_meta.set_num_segments(num_segments);
+        rs_meta.set_segments_overlap(overlap);
+        rs_meta.set_version({start_version, end_version});
+        if (is_row_binlog) {
+            rs_meta.mark_row_binlog();
+        }
+        EXPECT_EQ(rs_meta.is_segments_overlapping(), expected);
+    };
+
+    // Single segment is never overlapping, regardless of the flag.
+    check(1, OVERLAPPING, 2, 2, false, false);
+    check(1, OVERLAP_UNKNOWN, 2, 2, false, false);
+
+    // Multiple segments explicitly marked NONOVERLAPPING are not overlapping.
+    check(3, NONOVERLAPPING, 2, 2, false, false);
+    check(3, NONOVERLAPPING, 2, 5, false, false);
+
+    // Singleton delta (start == end) with overlapping / unknown segments is 
overlapping:
+    // a freshly ingested delta may have overlapping segments only tagged 
OVERLAP_UNKNOWN.
+    check(3, OVERLAPPING, 2, 2, false, true);
+    check(3, OVERLAP_UNKNOWN, 2, 2, false, true);
+
+    // Plain (non row-binlog) non-singleton + OVERLAPPING keeps the original 
semantics: a
+    // compaction output (start < end) is treated as non-overlapping.
+    check(3, OVERLAPPING, 2, 5, false, false);
+
+    // Row-binlog non-singleton explicitly marked OVERLAPPING is overlapping: 
this is the
+    // row-binlog LMax quick-merge output, which sets the flag explicitly.
+    check(3, OVERLAPPING, 2, 5, true, true);
+
+    // Row-binlog non-singleton left as OVERLAP_UNKNOWN must NOT be treated as 
overlapping.
+    check(3, OVERLAP_UNKNOWN, 2, 5, true, false);
+
+    // Old-metadata compatibility: a plain compaction output (start < end) 
left as
+    // OVERLAP_UNKNOWN must stay non-overlapping to avoid inflating
+    // get_compaction_score()/get_merge_way_num() and degrading ordered reads 
after upgrade.
+    check(3, OVERLAP_UNKNOWN, 2, 5, false, false);
+}
+
 } // namespace doris
diff --git a/be/test/storage/tablet_reader_test.cpp 
b/be/test/storage/tablet_reader_test.cpp
index 1c8f1e7712b..0978308c458 100644
--- a/be/test/storage/tablet_reader_test.cpp
+++ b/be/test/storage/tablet_reader_test.cpp
@@ -36,9 +36,10 @@ namespace doris {
 class TabletReaderTest : public testing::Test {
 protected:
     static TabletSchemaSPtr create_schema(
-            const std::vector<std::pair<std::string, int32_t>>& name_and_uid) {
+            const std::vector<std::pair<std::string, int32_t>>& name_and_uid,
+            KeysType keys_type = KeysType::DUP_KEYS) {
         TabletSchemaPB schema_pb;
-        schema_pb.set_keys_type(KeysType::DUP_KEYS);
+        schema_pb.set_keys_type(keys_type);
         bool first = true;
         for (const auto& [name, uid] : name_and_uid) {
             auto* col = schema_pb.add_column();
@@ -134,5 +135,4 @@ TEST_F(TabletReaderTest, 
remove_delete_columns_keeps_unrelated_paths) {
 
     EXPECT_EQ(size_t(2), access_paths.size());
 }
-
 } // namespace doris
diff --git 
a/regression-test/data/table_stream_p0/test_table_stream_multi_segment_mow.out 
b/regression-test/data/table_stream_p0/test_table_stream_multi_segment_mow.out
new file mode 100644
index 00000000000..7c7c051fd91
--- /dev/null
+++ 
b/regression-test/data/table_stream_p0/test_table_stream_multi_segment_mow.out
@@ -0,0 +1,79 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !mow_count --
+10000
+
+-- !mow_change_types --
+APPEND 10000
+
+-- !mow_sample --
+1      1       APPEND
+10000  10000   APPEND
+5000   5000    APPEND
+5001   5001    APPEND
+
+-- !mow_ov_count --
+7500
+
+-- !mow_ov_change_types --
+APPEND 7500
+
+-- !mow_ov_sample --
+1      1       APPEND
+2500   2500    APPEND
+2501   2501    APPEND
+5000   5000    APPEND
+5001   105001  APPEND
+7500   107500  APPEND
+
+-- !mow_md_count --
+10000
+
+-- !mow_md_change_types --
+APPEND 10000
+
+-- !mow_md_sample --
+1      1       APPEND
+10000  10000   APPEND
+5000   5000    APPEND
+5001   5001    APPEND
+
+-- !mow_md_ov_count --
+7500
+
+-- !mow_md_ov_change_types --
+APPEND 7500
+
+-- !mow_md_ov_sample --
+1      1       APPEND
+2500   2500    APPEND
+2501   102501  APPEND
+5000   105000  APPEND
+5001   105001  APPEND
+7500   107500  APPEND
+
+-- !mow_qm_binlog --
+0      1       10      \N
+0      2       20      \N
+0      3       30      \N
+1      1       11      10
+1      1       12      11
+1      1       13      12
+1      1       14      13
+1      2       21      20
+1      2       22      21
+1      2       23      22
+1      2       24      23
+1      3       31      30
+1      3       32      31
+
+-- !mow_qm_count --
+3
+
+-- !mow_qm_change_types --
+APPEND 3
+
+-- !mow_qm_sample --
+1      14      APPEND
+2      24      APPEND
+3      32      APPEND
+
diff --git 
a/regression-test/suites/table_stream_p0/test_table_stream_multi_segment_mow.groovy
 
b/regression-test/suites/table_stream_p0/test_table_stream_multi_segment_mow.groovy
new file mode 100644
index 00000000000..ad749e8aaba
--- /dev/null
+++ 
b/regression-test/suites/table_stream_p0/test_table_stream_multi_segment_mow.groovy
@@ -0,0 +1,538 @@
+// 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.
+
+suite("test_table_stream_multi_segment_mow", "nonConcurrent") {
+    if (isCloudMode()) {
+        return
+    }
+
+    sql "DROP DATABASE IF EXISTS test_table_stream_multi_segment_mow_db"
+    sql "CREATE DATABASE test_table_stream_multi_segment_mow_db"
+    sql "USE test_table_stream_multi_segment_mow_db"
+
+    def customBeConfig = [
+        doris_scanner_row_bytes: 1,
+        // Section E drives row-binlog LMax quick-merge. Tiny thresholds let 
every small
+        // rowset count as "compaction enough" so the cumulative point 
advances past two
+        // LMax rowsets, and wait_timesec=0 stops freshly-visible singleton 
deltas from
+        // being filtered out of the compaction candidate set. time_threshold 
is set huge
+        // so the time-based trigger never preempts the quick-merge branch.
+        binlog_compaction_goal_size_mbytes: 0,
+        binlog_compaction_file_count_threshold: 2,
+        binlog_compaction_wait_timesec_after_visible: 0,
+        binlog_compaction_time_threshold_seconds: 86400
+    ]
+
+    setBeConfigTemporary(customBeConfig) {
+        try {
+            GetDebugPoint().clearDebugPointsForAllBEs()
+            // shrink doris_scanner_row_bytes and enable MemTable.need_flush 
to make
+            // each 5000-row batch produce multiple segments stably.
+            GetDebugPoint().enableDebugPointForAllBEs("MemTable.need_flush")
+
+            String batch1 = ""
+            String batch2 = ""
+            (1..5000).each { batch1 += "${it},${it}\n" }
+            (5001..10000).each { batch2 += "${it},${it}\n" }
+
+
+            // ========================================================
+            // Section A. MoW + append_only.
+            // ========================================================
+            sql "DROP STREAM IF EXISTS ts_ms_mow_stream"
+            sql "DROP TABLE IF EXISTS ts_ms_mow_base FORCE"
+            sql """
+                CREATE TABLE ts_ms_mow_base (
+                    id BIGINT,
+                    v INT
+                ) ENGINE=OLAP
+                UNIQUE KEY(id)
+                DISTRIBUTED BY HASH(id) BUCKETS 1
+                PROPERTIES (
+                    "replication_num" = "1",
+                    "enable_unique_key_merge_on_write" = "true",
+                    "disable_auto_compaction" = "true",
+                    "binlog.enable" = "true",
+                    "binlog.format" = "ROW",
+                    "binlog.need_historical_value" = "true"
+                )
+            """
+            sql """
+                CREATE STREAM ts_ms_mow_stream
+                ON TABLE ts_ms_mow_base
+                PROPERTIES (
+                    "type" = "append_only",
+                    "show_initial_rows" = "false"
+                )
+            """
+            streamLoad {
+                db "test_table_stream_multi_segment_mow_db"
+                table "ts_ms_mow_base"
+                set 'column_separator', ','
+                set 'columns', 'id,v'
+                inputStream new ByteArrayInputStream(batch1.getBytes())
+                time 60000
+                check { result, exception, startTime, endTime ->
+                    if (exception != null) { throw exception }
+                    def json = parseJson(result)
+                    assertEquals("success", json.Status.toLowerCase())
+                    assertEquals(0, json.NumberFilteredRows)
+                }
+            }
+            streamLoad {
+                db "test_table_stream_multi_segment_mow_db"
+                table "ts_ms_mow_base"
+                set 'column_separator', ','
+                set 'columns', 'id,v'
+                inputStream new ByteArrayInputStream(batch2.getBytes())
+                time 60000
+                check { result, exception, startTime, endTime ->
+                    if (exception != null) { throw exception }
+                    def json = parseJson(result)
+                    assertEquals("success", json.Status.toLowerCase())
+                    assertEquals(0, json.NumberFilteredRows)
+                }
+            }
+            sql "sync"
+            sleep(1200)
+
+            order_qt_mow_count "SELECT COUNT(*) FROM ts_ms_mow_stream"
+            order_qt_mow_change_types """
+                SELECT __DORIS_STREAM_CHANGE_TYPE_COL__, COUNT(*)
+                FROM ts_ms_mow_stream
+                GROUP BY __DORIS_STREAM_CHANGE_TYPE_COL__
+                ORDER BY __DORIS_STREAM_CHANGE_TYPE_COL__
+            """
+            order_qt_mow_sample """
+                SELECT id, v, __DORIS_STREAM_CHANGE_TYPE_COL__
+                FROM ts_ms_mow_stream
+                WHERE id IN (1, 5000, 5001, 10000)
+                ORDER BY id
+            """
+
+            // ========================================================
+            // Section B. MoW + append_only, overlapping batches.
+            //   updates on existing keys are filtered, only brand-new keys 
append.
+            //     batch1: id [1, 5000]        -> 5000 new keys, all APPEND
+            //     batch2: id [2501, 7500]     -> [2501,5000] updates filtered,
+            //                                    [5001,7500] 2500 new keys 
APPEND
+            //     expect: 7500 APPEND rows.
+            // ========================================================
+            String overlapBatch1 = ""
+            String overlapBatch2 = ""
+            (1..5000).each { overlapBatch1 += "${it},${it}\n" }
+            (2501..7500).each { overlapBatch2 += "${it},${it + 100000}\n" }
+
+            sql "DROP STREAM IF EXISTS ts_ms_mow_ov_stream"
+            sql "DROP TABLE IF EXISTS ts_ms_mow_ov_base FORCE"
+            sql """
+                CREATE TABLE ts_ms_mow_ov_base (
+                    id BIGINT,
+                    v INT
+                ) ENGINE=OLAP
+                UNIQUE KEY(id)
+                DISTRIBUTED BY HASH(id) BUCKETS 1
+                PROPERTIES (
+                    "replication_num" = "1",
+                    "enable_unique_key_merge_on_write" = "true",
+                    "disable_auto_compaction" = "true",
+                    "binlog.enable" = "true",
+                    "binlog.format" = "ROW",
+                    "binlog.need_historical_value" = "true"
+                )
+            """
+            sql """
+                CREATE STREAM ts_ms_mow_ov_stream
+                ON TABLE ts_ms_mow_ov_base
+                PROPERTIES (
+                    "type" = "append_only",
+                    "show_initial_rows" = "false"
+                )
+            """
+            streamLoad {
+                db "test_table_stream_multi_segment_mow_db"
+                table "ts_ms_mow_ov_base"
+                set 'column_separator', ','
+                set 'columns', 'id,v'
+                inputStream new ByteArrayInputStream(overlapBatch1.getBytes())
+                time 60000
+                check { result, exception, startTime, endTime ->
+                    if (exception != null) { throw exception }
+                    def json = parseJson(result)
+                    assertEquals("success", json.Status.toLowerCase())
+                    assertEquals(0, json.NumberFilteredRows)
+                }
+            }
+            streamLoad {
+                db "test_table_stream_multi_segment_mow_db"
+                table "ts_ms_mow_ov_base"
+                set 'column_separator', ','
+                set 'columns', 'id,v'
+                inputStream new ByteArrayInputStream(overlapBatch2.getBytes())
+                time 60000
+                check { result, exception, startTime, endTime ->
+                    if (exception != null) { throw exception }
+                    def json = parseJson(result)
+                    assertEquals("success", json.Status.toLowerCase())
+                    assertEquals(0, json.NumberFilteredRows)
+                }
+            }
+            sql "sync"
+            sleep(1200)
+
+            qt_mow_ov_count "SELECT COUNT(*) FROM ts_ms_mow_ov_stream"
+            order_qt_mow_ov_change_types """
+                SELECT __DORIS_STREAM_CHANGE_TYPE_COL__, COUNT(*)
+                FROM ts_ms_mow_ov_stream
+                GROUP BY __DORIS_STREAM_CHANGE_TYPE_COL__
+                ORDER BY __DORIS_STREAM_CHANGE_TYPE_COL__
+            """
+            order_qt_mow_ov_sample """
+                SELECT id, v, __DORIS_STREAM_CHANGE_TYPE_COL__
+                FROM ts_ms_mow_ov_stream
+                WHERE id IN (1, 2500, 2501, 5000, 5001, 7500)
+                ORDER BY id
+            """
+
+            // ========================================================
+            // Section C. MoW + min_delta, non-overlapping batches.
+            //     batch1: id [1, 5000]      -> 5000 brand-new keys
+            //     batch2: id [5001, 10000]  -> 5000 brand-new keys
+            //     each key inserted once, nothing to fold -> 10000 APPEND 
rows.
+            // ========================================================
+            sql "DROP STREAM IF EXISTS ts_ms_mow_md_stream"
+            sql "DROP TABLE IF EXISTS ts_ms_mow_md_base FORCE"
+            sql """
+                CREATE TABLE ts_ms_mow_md_base (
+                    id BIGINT,
+                    v INT
+                ) ENGINE=OLAP
+                UNIQUE KEY(id)
+                DISTRIBUTED BY HASH(id) BUCKETS 1
+                PROPERTIES (
+                    "replication_num" = "1",
+                    "enable_unique_key_merge_on_write" = "true",
+                    "disable_auto_compaction" = "true",
+                    "binlog.enable" = "true",
+                    "binlog.format" = "ROW",
+                    "binlog.need_historical_value" = "true"
+                )
+            """
+            sql """
+                CREATE STREAM ts_ms_mow_md_stream
+                ON TABLE ts_ms_mow_md_base
+                PROPERTIES (
+                    "type" = "min_delta",
+                    "show_initial_rows" = "false"
+                )
+            """
+            streamLoad {
+                db "test_table_stream_multi_segment_mow_db"
+                table "ts_ms_mow_md_base"
+                set 'column_separator', ','
+                set 'columns', 'id,v'
+                inputStream new ByteArrayInputStream(batch1.getBytes())
+                time 60000
+                check { result, exception, startTime, endTime ->
+                    if (exception != null) { throw exception }
+                    def json = parseJson(result)
+                    assertEquals("success", json.Status.toLowerCase())
+                    assertEquals(0, json.NumberFilteredRows)
+                }
+            }
+            streamLoad {
+                db "test_table_stream_multi_segment_mow_db"
+                table "ts_ms_mow_md_base"
+                set 'column_separator', ','
+                set 'columns', 'id,v'
+                inputStream new ByteArrayInputStream(batch2.getBytes())
+                time 60000
+                check { result, exception, startTime, endTime ->
+                    if (exception != null) { throw exception }
+                    def json = parseJson(result)
+                    assertEquals("success", json.Status.toLowerCase())
+                    assertEquals(0, json.NumberFilteredRows)
+                }
+            }
+            sql "sync"
+            sleep(1200)
+
+            qt_mow_md_count "SELECT COUNT(*) FROM ts_ms_mow_md_stream"
+            order_qt_mow_md_change_types """
+                SELECT __DORIS_STREAM_CHANGE_TYPE_COL__, COUNT(*)
+                FROM ts_ms_mow_md_stream
+                GROUP BY __DORIS_STREAM_CHANGE_TYPE_COL__
+                ORDER BY __DORIS_STREAM_CHANGE_TYPE_COL__
+            """
+            order_qt_mow_md_sample """
+                SELECT id, v, __DORIS_STREAM_CHANGE_TYPE_COL__
+                FROM ts_ms_mow_md_stream
+                WHERE id IN (1, 5000, 5001, 10000)
+                ORDER BY id
+            """
+
+            // ========================================================
+            // Section D. MoW + min_delta, overlapping batches.
+            //   Combines Section B's overlapping layout with min_delta 
folding.
+            //   The stream is created on an empty table 
(show_initial_rows=false),
+            //   so there is no historical baseline to act as UPDATE_BEFORE. 
Every
+            //   key's net change relative to the stream start is therefore a 
single
+            //   APPEND carrying the latest value; an insert followed by an 
update
+            //   folds to one APPEND(new value), NOT an 
UPDATE_BEFORE/UPDATE_AFTER
+            //   pair. The point is that min_delta must keep every net-changed 
key
+            //   without dropping events across segments.
+            //     batch1: id [1, 5000]      v = id
+            //     batch2: id [2501, 7500]   v = id + 100000
+            //   expect: 7500 APPEND rows, overlapping keys carrying the 
latest value.
+            //     [1, 2500]     inserted once           -> APPEND(id)
+            //     [2501, 5000]  insert then update       -> APPEND(id + 
100000)
+            //     [5001, 7500]  inserted once           -> APPEND(id + 100000)
+            // ========================================================
+            sql "DROP STREAM IF EXISTS ts_ms_mow_md_ov_stream"
+            sql "DROP TABLE IF EXISTS ts_ms_mow_md_ov_base FORCE"
+            sql """
+                CREATE TABLE ts_ms_mow_md_ov_base (
+                    id BIGINT,
+                    v INT
+                ) ENGINE=OLAP
+                UNIQUE KEY(id)
+                DISTRIBUTED BY HASH(id) BUCKETS 1
+                PROPERTIES (
+                    "replication_num" = "1",
+                    "enable_unique_key_merge_on_write" = "true",
+                    "disable_auto_compaction" = "true",
+                    "binlog.enable" = "true",
+                    "binlog.format" = "ROW",
+                    "binlog.need_historical_value" = "true"
+                )
+            """
+            sql """
+                CREATE STREAM ts_ms_mow_md_ov_stream
+                ON TABLE ts_ms_mow_md_ov_base
+                PROPERTIES (
+                    "type" = "min_delta",
+                    "show_initial_rows" = "false"
+                )
+            """
+            streamLoad {
+                db "test_table_stream_multi_segment_mow_db"
+                table "ts_ms_mow_md_ov_base"
+                set 'column_separator', ','
+                set 'columns', 'id,v'
+                inputStream new ByteArrayInputStream(overlapBatch1.getBytes())
+                time 60000
+                check { result, exception, startTime, endTime ->
+                    if (exception != null) { throw exception }
+                    def json = parseJson(result)
+                    assertEquals("success", json.Status.toLowerCase())
+                    assertEquals(0, json.NumberFilteredRows)
+                }
+            }
+            streamLoad {
+                db "test_table_stream_multi_segment_mow_db"
+                table "ts_ms_mow_md_ov_base"
+                set 'column_separator', ','
+                set 'columns', 'id,v'
+                inputStream new ByteArrayInputStream(overlapBatch2.getBytes())
+                time 60000
+                check { result, exception, startTime, endTime ->
+                    if (exception != null) { throw exception }
+                    def json = parseJson(result)
+                    assertEquals("success", json.Status.toLowerCase())
+                    assertEquals(0, json.NumberFilteredRows)
+                }
+            }
+            sql "sync"
+            sleep(1200)
+
+            qt_mow_md_ov_count "SELECT COUNT(*) FROM ts_ms_mow_md_ov_stream"
+            order_qt_mow_md_ov_change_types """
+                SELECT __DORIS_STREAM_CHANGE_TYPE_COL__, COUNT(*)
+                FROM ts_ms_mow_md_ov_stream
+                GROUP BY __DORIS_STREAM_CHANGE_TYPE_COL__
+                ORDER BY __DORIS_STREAM_CHANGE_TYPE_COL__
+            """
+            order_qt_mow_md_ov_sample """
+                SELECT id, v, __DORIS_STREAM_CHANGE_TYPE_COL__
+                FROM ts_ms_mow_md_ov_stream
+                WHERE id IN (1, 2500, 2501, 5000, 5001, 7500)
+                ORDER BY id
+            """
+
+            // ========================================================
+            // Section E. MoW + row-binlog LMax quick-merge.
+            //   Sections A-D produce a single multi-segment rowset per load. 
This section
+            //   targets the OTHER multi-segment source: binlog cumulative 
compaction's LMax
+            //   quick-merge, which LINKS segments from several row-binlog 
rowsets into one
+            //   non-singleton OVERLAPPING output rowset (compaction.cpp sets 
segments_overlap
+            //   = OVERLAPPING for the quick-merge path). Because the same 
user key recurs in
+            //   both merged LMax rowsets, after the link it lands in 
different segments of the
+            //   one output rowset, each carrying a distinct binlog TSO. 
Reading it must NOT
+            //   deduplicate by user key (is_unique=false on the row-binlog 
path); a UNIQUE
+            //   dedup would drop same-key/different-TSO events. This 
reproduces that layout and
+            //   asserts every event survives.
+            //
+            //   The BE binlog thresholds are lowered in customBeConfig so 
every small rowset is
+            //   "compaction enough" and the cumulative point advances past 
two LMax rowsets.
+            //   trigger_and_wait_compaction on the base table also drives the 
co-located hidden
+            //   row-binlog tablet (SHOW TABLETS lists it; its 
compaction_policy="binlog"), so the
+            //   cumulative trigger runs BinlogCumulativeCompactionPolicy on 
it. The load/trigger
+            //   cadence mirrors test_binlog_compaction.groovy case2's 
Round1..Round7.
+            // ========================================================
+            sql "DROP STREAM IF EXISTS ts_ms_mow_qm_stream"
+            sql "DROP TABLE IF EXISTS ts_ms_mow_qm_base FORCE"
+            sql """
+                CREATE TABLE ts_ms_mow_qm_base (
+                    id BIGINT,
+                    v INT
+                ) ENGINE=OLAP
+                UNIQUE KEY(id)
+                DISTRIBUTED BY HASH(id) BUCKETS 1
+                PROPERTIES (
+                    "replication_num" = "1",
+                    "enable_unique_key_merge_on_write" = "true",
+                    "disable_auto_compaction" = "true",
+                    "binlog.enable" = "true",
+                    "binlog.format" = "ROW",
+                    "binlog.need_historical_value" = "true"
+                )
+            """
+            sql """
+                CREATE STREAM ts_ms_mow_qm_stream
+                ON TABLE ts_ms_mow_qm_base
+                PROPERTIES (
+                    "type" = "min_delta",
+                    "show_initial_rows" = "false"
+                )
+            """
+
+            // Build the FIRST LMax rowset [0-6]. Each INSERT is one binlog 
version.
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (1, 10), (2, 20)" // v1
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (1, 11)"          // v2
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (2, 21)"          // v3
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (3, 30)"          // v4
+            // Round 1: Level0 [0-1],[2-2],[3-3],[4-4] -> Level1 [0-4].
+            trigger_and_wait_compaction("ts_ms_mow_qm_base", "cumulative")
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (1, 12)"          // v5
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (2, 22)"          // v6
+            // Round 2: Level0 [5-5],[6-6] -> Level1 [5-6].
+            trigger_and_wait_compaction("ts_ms_mow_qm_base", "cumulative")
+            // Round 3: Level1 [0-4],[5-6] -> Level2 (LMax) [0-6].
+            trigger_and_wait_compaction("ts_ms_mow_qm_base", "cumulative")
+
+            // Build the SECOND LMax rowset [7-12], reusing keys 1/2/3 so they 
recur across
+            // both LMax rowsets (distinct TSOs), producing same-key rows in 
different segments
+            // after the quick-merge link.
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (1, 13)"          // v7
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (2, 23)"          // v8
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (3, 31)"          // v9
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (1, 14)"          // v10
+            // Round 4: Level0 [7-7],[8-8],[9-9],[10-10] -> Level1 [7-10].
+            trigger_and_wait_compaction("ts_ms_mow_qm_base", "cumulative")
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (2, 24)"          // v11
+            sql "INSERT INTO ts_ms_mow_qm_base VALUES (3, 32)"          // v12
+            // Round 5: Level0 [11-11],[12-12] -> Level1 [11-12].
+            trigger_and_wait_compaction("ts_ms_mow_qm_base", "cumulative")
+            // Round 6: Level1 [7-10],[11-12] -> Level2 (LMax) [7-12].
+            trigger_and_wait_compaction("ts_ms_mow_qm_base", "cumulative")
+
+            // Round 7: Level2 {[0-6],[7-12]} are both before the cumulative 
point and both
+            // compaction-enough (compact_enough_size=2>1) -> LMax quick-merge 
-> [0-12] with
+            // segments_overlap=OVERLAPPING. This is the non-singleton 
overlapping rowset.
+            trigger_and_wait_compaction("ts_ms_mow_qm_base", "cumulative")
+            sql "sync"
+            sleep(1200)
+
+            // Guard: assert the quick-merge actually produced the target 
rowset before querying.
+            // trigger_and_wait_compaction drives every tablet of the table 
via compact_type=
+            // cumulative, including the co-located hidden row-binlog tablet 
(IsRowBinlog=true,
+            // compaction_policy="binlog"), which BE routes to 
BinlogCumulativeCompactionPolicy +
+            // LMax quick-merge. /api/compaction/show returns each rowset as
+            //   "[start-end] <num_segments> DATA <OVERLAP> <rowset_id> <size> 
level=<n>"
+            // (Rowset::get_rowset_info_str). The quick-merge signature 
(compaction.cpp: output
+            // version = [front.start, back.end] with start_version==0 the 
trigger precondition,
+            // and segments_overlap forced to OVERLAPPING) is: on the 
row-binlog tablet, a rowset
+            // that starts at version 0, is OVERLAPPING, and links more than 
one segment. We match
+            // that rather than a hardcoded end version, since the row-binlog 
tablet's version
+            // range depends on the initial [0-1] rowset (here the merged 
rowset is [0-13]).
+            // Without this guard, a compaction that never hit the quick-merge 
branch would
+            // silently fall back to reading the original rowsets and the 
section could pass
+            // without exercising the intended path.
+            def qmBackendIdToIp = [:]
+            def qmBackendIdToHttpPort = [:]
+            getBackendIpHttpPort(qmBackendIdToIp, qmBackendIdToHttpPort)
+            def qmTablets = sql_return_maparray "show tablets from 
ts_ms_mow_qm_base"
+            def foundQuickMergeRowset = false
+            for (qmTablet in qmTablets) {
+                // The quick-merge OVERLAPPING rowset only lives on the hidden 
row-binlog tablet.
+                if (qmTablet.IsRowBinlog != "true") {
+                    continue
+                }
+                def qmBeHost = qmBackendIdToIp["${qmTablet.BackendId}"]
+                def qmBePort = qmBackendIdToHttpPort["${qmTablet.BackendId}"]
+                def (qmExitCode, qmStdout, qmStderr) =
+                        be_show_tablet_status(qmBeHost, qmBePort, 
qmTablet.TabletId)
+                assert qmExitCode == 0 : "show tablet status failed: 
${qmStderr}"
+                def qmStatus = parseJson(qmStdout.trim())
+                logger.info("row-binlog tablet ${qmTablet.TabletId} policy=" +
+                        "${qmStatus['cumulative policy type']} 
rowsets=${qmStatus.rowsets}")
+                for (rowsetStr in qmStatus.rowsets) {
+                    // e.g. "[0-13] 2 DATA OVERLAPPING <id> 2.80 KB level=0"
+                    def fields = rowsetStr.trim().split(/\s+/)
+                    if (fields.length >= 4 && fields[0].startsWith("[0-") &&
+                            fields[1].isInteger() && (fields[1] as int) > 1 &&
+                            fields[3] == "OVERLAPPING") {
+                        foundQuickMergeRowset = true
+                        logger.info("found LMax quick-merge rowset on 
row-binlog tablet " +
+                                "${qmTablet.TabletId}: ${rowsetStr}")
+                    }
+                }
+            }
+            assert foundQuickMergeRowset :
+                    "expected a non-singleton multi-segment OVERLAPPING 
row-binlog rowset starting " +
+                    "at version 0 from LMax quick-merge, but none was found; 
row-binlog quick-merge " +
+                    "did not run and Section E would read the original rowsets 
instead"
+
+            // Ground truth for "no dropped events": the raw row-binlog op 
stream. This reads
+            // the quick-merge OVERLAPPING rowset through the same row-binlog 
TabletReader path
+            // (is_unique=false); a UNIQUE dedup would silently drop 
same-key/different-TSO rows,
+            // shrinking this result.
+            order_qt_mow_qm_binlog """
+                SELECT __DORIS_BINLOG_OP__ AS op,
+                       id,
+                       v,
+                       __BEFORE__v__
+                FROM binlog("table" = "ts_ms_mow_qm_base")
+                ORDER BY id, v, op
+            """
+            order_qt_mow_qm_count "SELECT COUNT(*) FROM ts_ms_mow_qm_stream"
+            order_qt_mow_qm_change_types """
+                SELECT __DORIS_STREAM_CHANGE_TYPE_COL__, COUNT(*)
+                FROM ts_ms_mow_qm_stream
+                GROUP BY __DORIS_STREAM_CHANGE_TYPE_COL__
+                ORDER BY __DORIS_STREAM_CHANGE_TYPE_COL__
+            """
+            order_qt_mow_qm_sample """
+                SELECT id, v, __DORIS_STREAM_CHANGE_TYPE_COL__
+                FROM ts_ms_mow_qm_stream
+                ORDER BY id, v
+            """
+        } finally {
+            GetDebugPoint().clearDebugPointsForAllBEs()
+        }
+    }
+}
\ No newline at end of file


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

Reply via email to