This is an automated email from the ASF dual-hosted git repository.
morningman 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 60ba6aef642 [fix](table stream) min delta delete op should return
first op's before value (#65826)
60ba6aef642 is described below
commit 60ba6aef642fa9aaf22d88d7977de9055c1855b3
Author: TsukiokaKogane <[email protected]>
AuthorDate: Thu Jul 23 13:48:04 2026 +0800
[fix](table stream) min delta delete op should return first op's before
value (#65826)
---
be/src/storage/iterator/block_reader.cpp | 13 +-
.../block_reader_change_next_block_test.cpp | 483 +++++++++++++++++++++
.../test_mow_min_delta_delete_before.out | 7 +
...olap_table_stream_mixed_history_incremental.out | 1 +
.../test_table_stream_query_comprehensive.out | 1 +
.../table_stream_p0/test_min_delta_stream.groovy | 2 +-
.../test_mow_min_delta_delete_before.groovy | 125 ++++++
7 files changed, 626 insertions(+), 6 deletions(-)
diff --git a/be/src/storage/iterator/block_reader.cpp
b/be/src/storage/iterator/block_reader.cpp
index e96f7a0aa76..16389c84a9e 100644
--- a/be/src/storage/iterator/block_reader.cpp
+++ b/be/src/storage/iterator/block_reader.cpp
@@ -265,6 +265,7 @@ Status BlockReader::_min_delta_next_block(Block* block,
bool* eof) {
RETURN_IF_ERROR(_write_binlog_op(*target_columns[target_col_idx],
binlog::STREAM_CHANGE_INSERT));
} else {
+ // insert should use most recent value
target_columns[target_col_idx]->insert_from(*_stored_data_columns[idx],
group_size -
1);
}
@@ -277,9 +278,14 @@ Status BlockReader::_min_delta_next_block(Block* block,
bool* eof) {
if (idx == _binlog_op_pos) {
RETURN_IF_ERROR(_write_binlog_op(*target_columns[target_col_idx],
binlog::STREAM_CHANGE_DELETE));
- } else {
+ } else if (idx == _binlog_lsn_pos || idx == _binlog_tso_pos) {
target_columns[target_col_idx]->insert_from(*_stored_data_columns[idx],
group_size -
1);
+ } else {
+ // delete should use first op value
+ int source_idx = _resolve_source_column_index(idx, true);
+
target_columns[target_col_idx]->insert_from(*_stored_data_columns[source_idx],
+ 0);
}
}
output_row_count++;
@@ -290,9 +296,6 @@ Status BlockReader::_min_delta_next_block(Block* block,
bool* eof) {
if (idx == _binlog_op_pos) {
RETURN_IF_ERROR(_write_binlog_op(*target_columns[target_col_idx],
binlog::STREAM_CHANGE_UPDATE_BEFORE));
- } else if (idx == _binlog_lsn_pos) {
-
target_columns[target_col_idx]->insert_from(*_stored_data_columns[idx],
- group_size -
1);
} else {
int source_idx = _resolve_source_column_index(idx, true);
target_columns[target_col_idx]->insert_from(*_stored_data_columns[source_idx],
@@ -381,7 +384,7 @@ Status BlockReader::_detail_change_next_block(Block* block,
bool* eof) {
output_row_count++;
} else if (op == ROW_BINLOG_DELETE) {
RETURN_IF_ERROR(_append_change_row(target_columns, source_block,
row,
- binlog::STREAM_CHANGE_DELETE,
false));
+ binlog::STREAM_CHANGE_DELETE,
true));
output_row_count++;
}
diff --git a/be/test/storage/iterator/block_reader_change_next_block_test.cpp
b/be/test/storage/iterator/block_reader_change_next_block_test.cpp
new file mode 100644
index 00000000000..bfa774c325c
--- /dev/null
+++ b/be/test/storage/iterator/block_reader_change_next_block_test.cpp
@@ -0,0 +1,483 @@
+// 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 branch coverage for BlockReader::_min_delta_next_block and
+// BlockReader::_detail_change_next_block. Instead of standing up a real
tablet /
+// rowset stack, we inject a fake VCollectIterator::LevelIterator that walks a
+// pre-built merged binlog block one row at a time, so we can exercise every
+// min-delta fold result (SKIP / INSERT / DELETE / UPDATE_BEFORE_AFTER) and
every
+// detail op (INSERT / DELETE / UPDATE, including the pending-row batch split).
+
+#if defined(__clang__)
+#pragma clang diagnostic push
+#pragma clang diagnostic ignored "-Wkeyword-macro"
+#endif
+#define private public
+#define protected public
+#include "storage/iterator/block_reader.h"
+#include "storage/iterator/vcollect_iterator.h"
+#undef private
+#undef protected
+#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 "storage/binlog.h"
+#include "storage/iterator/binlog_block_reader_utils.h"
+#include "storage/tablet/tablet_schema.h"
+#include "storage/utils.h"
+
+namespace doris {
+
+using namespace ErrorCode;
+
+namespace {
+
+// Merged binlog block schema used across the tests. Column layout mirrors
what a
+// row-binlog scan produces after merge:
+// 0: key (Int64, the primary key used to group same-key rows)
+// 1: val (Int64, the "after" value of a data column)
+// 2: __BEFORE__val__ (Int64, the "before" value mirror of `val`)
+// 3: __DORIS_BINLOG_TSO__ (Int64)
+// 4: __DORIS_BINLOG_LSN__ (Int64)
+// 5: __DORIS_BINLOG_OP__ (Int64, one of 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;
+
+struct Row {
+ int64_t key;
+ int64_t val;
+ int64_t before_val;
+ int64_t tso;
+ int64_t lsn;
+ int64_t op;
+};
+
+std::shared_ptr<Block> make_source_block(const std::vector<Row>& rows) {
+ auto block = std::make_shared<Block>();
+ auto type = std::make_shared<DataTypeInt64>();
+
+ auto key_col = ColumnInt64::create();
+ auto val_col = ColumnInt64::create();
+ auto before_col = ColumnInt64::create();
+ auto tso_col = ColumnInt64::create();
+ auto lsn_col = ColumnInt64::create();
+ auto op_col = ColumnInt64::create();
+ for (const auto& r : rows) {
+ key_col->insert_value(r.key);
+ val_col->insert_value(r.val);
+ before_col->insert_value(r.before_val);
+ tso_col->insert_value(r.tso);
+ lsn_col->insert_value(r.lsn);
+ op_col->insert_value(r.op);
+ }
+ block->insert({std::move(key_col), type, "key"});
+ block->insert({std::move(val_col), type, "val"});
+ block->insert({std::move(before_col), type,
binlog::build_before_column_name("val")});
+ block->insert({std::move(tso_col), type, BINLOG_TSO_COL});
+ block->insert({std::move(lsn_col), type, BINLOG_LSN_COL});
+ block->insert({std::move(op_col), type, BINLOG_OP_COL});
+ return block;
+}
+
+// Fake merge iterator: hands out rows from `source` one at a time. `is_same`
is
+// derived from primary-key equality with the previous emitted row, matching
the
+// real merge iterator's contract that consecutive same-key rows are flagged.
+class FakeLevelIterator : public VCollectIterator::LevelIterator {
+public:
+ FakeLevelIterator(TabletReader* reader, std::shared_ptr<Block> source)
+ : LevelIterator(reader), _source(std::move(source)) {
+ _ref.block = _source;
+ _ref.row_pos = 0;
+ _ref.is_same = false;
+ }
+
+ Status init(bool /*get_data_by_ref*/) override { return Status::OK(); }
+ int64_t version() const override { return 0; }
+
+ Status next(IteratorRowRef* ref) override {
+ int prev = _ref.row_pos;
+ int next_pos = prev + 1;
+ if (next_pos >= _source->rows()) {
+ _ref.row_pos = -1;
+ *ref = _ref;
+ return Status::Error<END_OF_FILE>("");
+ }
+ int64_t prev_key = read_i64(KEY_IDX, prev);
+ int64_t cur_key = read_i64(KEY_IDX, next_pos);
+ _ref.row_pos = next_pos;
+ _ref.is_same = (prev_key == cur_key);
+ *ref = _ref;
+ return Status::OK();
+ }
+
+ Status next(Block* /*block*/) override { return
Status::Error<END_OF_FILE>(""); }
+
+ RowLocation current_row_location() override { return RowLocation(); }
+ Status current_block_row_locations(std::vector<RowLocation>* /*loc*/)
override {
+ return Status::OK();
+ }
+ Status ensure_first_row_ref() override { return Status::OK(); }
+ void update_profile(RuntimeProfile* /*profile*/) override {}
+
+private:
+ int64_t read_i64(int col, int row) const {
+ return assert_cast<const
ColumnInt64&>(*_source->get_by_position(col).column)
+ .get_element(row);
+ }
+
+ std::shared_ptr<Block> _source;
+};
+
+// Wire a BlockReader as if init() had already completed for a row-binlog
change
+// scan over the fixed 6-column schema, then plug in the fake merge iterator.
+void configure_reader(BlockReader& reader, std::shared_ptr<Block> source,
size_t batch_size) {
+ config::enable_adaptive_batch_size = false;
+ reader._reader_context.batch_size = batch_size;
+
+ // The fake LevelIterator base ctor dereferences reader->tablet_schema(),
so a
+ // schema must exist even though its contents are unused by these code
paths.
+ reader._tablet_schema = std::make_shared<TabletSchema>();
+
+ // All 6 columns are "normal" columns and are returned in-place.
+ reader._normal_columns_idx = {KEY_IDX, VAL_IDX, BEFORE_VAL_IDX, TSO_IDX,
LSN_IDX, OP_IDX};
+ reader._return_columns_loc = {0, 1, 2, 3, 4, 5};
+
+ reader._next_row.block = source;
+ reader._next_row.row_pos = 0;
+ reader._next_row.is_same = false;
+ reader._eof = false;
+
+ reader._vcollect_iter._inner_iter =
+ std::make_unique<FakeLevelIterator>(&reader, std::move(source));
+}
+
+// Build the empty output block matching the source schema.
+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;
+};
+
+// Drain the reader across as many next_block calls as needed and collect the
+// (key, val, op) triples in output order.
+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 BlockReaderChangeNextBlockTest : 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;
+};
+
+// ============================================================================
+// _min_delta_next_block branch coverage
+// ============================================================================
+
+// APPEND then DELETE within the window folds to SKIP: nothing is emitted.
+TEST_F(BlockReaderChangeNextBlockTest, MinDeltaSkip) {
+ auto source = make_source_block({
+ {/*key=*/1, /*val=*/10, /*before=*/0, /*tso=*/1, /*lsn=*/1,
ROW_BINLOG_APPEND},
+ {/*key=*/1, /*val=*/10, /*before=*/10, /*tso=*/2, /*lsn=*/2,
ROW_BINLOG_DELETE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, /*batch_size=*/16);
+
+ auto out = drain(reader, &BlockReader::_min_delta_next_block);
+ EXPECT_TRUE(out.empty());
+}
+
+// APPEND (+ later UPDATE) folds to a single INSERT carrying the most recent
value.
+TEST_F(BlockReaderChangeNextBlockTest, MinDeltaInsert) {
+ auto source = make_source_block({
+ {1, 10, 0, 1, 1, ROW_BINLOG_APPEND},
+ {1, 20, 10, 2, 2, ROW_BINLOG_UPDATE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 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
+}
+
+// UPDATE then DELETE folds to a single DELETE carrying the first op's before
value.
+TEST_F(BlockReaderChangeNextBlockTest, MinDeltaDelete) {
+ auto source = make_source_block({
+ {1, 20, 10, 1, 1, ROW_BINLOG_UPDATE},
+ {1, 20, 20, 2, 2, ROW_BINLOG_DELETE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 16);
+
+ auto out = drain(reader, &BlockReader::_min_delta_next_block);
+ ASSERT_EQ(out.size(), 1);
+ EXPECT_EQ(out[0].op, binlog::STREAM_CHANGE_DELETE);
+ EXPECT_EQ(out[0].key, 1);
+ // delete uses the first op's before value (val's __BEFORE__ mirror of row
0).
+ EXPECT_EQ(out[0].val, 10);
+}
+
+// UPDATE then UPDATE folds to a BEFORE + AFTER pair.
+TEST_F(BlockReaderChangeNextBlockTest, MinDeltaUpdateBeforeAfter) {
+ auto source = make_source_block({
+ {1, 20, 10, 1, 1, ROW_BINLOG_UPDATE},
+ {1, 30, 20, 2, 2, ROW_BINLOG_UPDATE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 16);
+
+ auto out = drain(reader, &BlockReader::_min_delta_next_block);
+ ASSERT_EQ(out.size(), 2);
+ EXPECT_EQ(out[0].op, binlog::STREAM_CHANGE_UPDATE_BEFORE);
+ EXPECT_EQ(out[0].val, 10); // before value from the first op
+ EXPECT_EQ(out[1].op, binlog::STREAM_CHANGE_UPDATE_AFTER);
+ EXPECT_EQ(out[1].val, 30); // after value from the last op
+}
+
+// Multiple distinct keys, each in its own group, are folded independently.
+TEST_F(BlockReaderChangeNextBlockTest, MinDeltaMultipleKeys) {
+ auto source = make_source_block({
+ // key 1: APPEND -> INSERT(val=10)
+ {1, 10, 0, 1, 1, ROW_BINLOG_APPEND},
+ // key 2: APPEND + DELETE -> SKIP
+ {2, 20, 0, 2, 2, ROW_BINLOG_APPEND},
+ {2, 20, 20, 3, 3, ROW_BINLOG_DELETE},
+ // key 3: UPDATE + DELETE -> DELETE(before=30)
+ {3, 40, 30, 4, 4, ROW_BINLOG_UPDATE},
+ {3, 40, 40, 5, 5, ROW_BINLOG_DELETE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 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, 10);
+ EXPECT_EQ(out[1].key, 3);
+ EXPECT_EQ(out[1].op, binlog::STREAM_CHANGE_DELETE);
+ EXPECT_EQ(out[1].val, 30);
+}
+
+// The UPDATE_BEFORE_AFTER split across a batch boundary: BEFORE emitted at the
+// end of one block, AFTER carried over via _pending_row_columns to the next.
+TEST_F(BlockReaderChangeNextBlockTest, MinDeltaUpdatePendingRowAcrossBatch) {
+ auto source = make_source_block({
+ // key 1: single INSERT fills row 0 of the first batch.
+ {1, 10, 0, 1, 1, ROW_BINLOG_APPEND},
+ // key 2: UPDATE+UPDATE -> BEFORE lands on row 1 (== batch_size),
so
+ // AFTER must be parked and flushed on the next call.
+ {2, 20, 15, 2, 2, ROW_BINLOG_UPDATE},
+ {2, 30, 20, 3, 3, ROW_BINLOG_UPDATE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, /*batch_size=*/2);
+
+ bool eof = false;
+ Block block1 = make_output_block();
+ ASSERT_TRUE(reader._min_delta_next_block(&block1, &eof).ok());
+ ASSERT_EQ(block1.rows(), 2);
+ EXPECT_EQ(out_i64(block1, OP_IDX, 0), binlog::STREAM_CHANGE_INSERT);
+ EXPECT_EQ(out_i64(block1, OP_IDX, 1), binlog::STREAM_CHANGE_UPDATE_BEFORE);
+ EXPECT_EQ(out_i64(block1, VAL_IDX, 1), 15); // before value
+ EXPECT_FALSE(eof);
+ EXPECT_TRUE(reader._has_pending_row);
+
+ Block block2 = make_output_block();
+ ASSERT_TRUE(reader._min_delta_next_block(&block2, &eof).ok());
+ ASSERT_EQ(block2.rows(), 1);
+ EXPECT_EQ(out_i64(block2, OP_IDX, 0), binlog::STREAM_CHANGE_UPDATE_AFTER);
+ EXPECT_EQ(out_i64(block2, VAL_IDX, 0), 30); // after value
+ EXPECT_TRUE(eof);
+}
+
+// ============================================================================
+// _detail_change_next_block branch coverage
+// ============================================================================
+
+// APPEND -> a single INSERT row with the row's value.
+TEST_F(BlockReaderChangeNextBlockTest, DetailInsert) {
+ auto source = make_source_block({
+ {1, 10, 0, 1, 1, ROW_BINLOG_APPEND},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 16);
+
+ auto out = drain(reader, &BlockReader::_detail_change_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, 10);
+}
+
+// DELETE -> a single DELETE row that uses the before value.
+TEST_F(BlockReaderChangeNextBlockTest, DetailDelete) {
+ auto source = make_source_block({
+ {1, 10, 99, 1, 1, ROW_BINLOG_DELETE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 16);
+
+ auto out = drain(reader, &BlockReader::_detail_change_next_block);
+ ASSERT_EQ(out.size(), 1);
+ EXPECT_EQ(out[0].op, binlog::STREAM_CHANGE_DELETE);
+ EXPECT_EQ(out[0].key, 1);
+ EXPECT_EQ(out[0].val, 99); // delete uses __BEFORE__ mirror
+}
+
+// UPDATE -> a BEFORE (before value) + AFTER (after value) pair.
+TEST_F(BlockReaderChangeNextBlockTest, DetailUpdatePair) {
+ auto source = make_source_block({
+ {1, 20, 10, 1, 1, ROW_BINLOG_UPDATE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 16);
+
+ auto out = drain(reader, &BlockReader::_detail_change_next_block);
+ ASSERT_EQ(out.size(), 2);
+ EXPECT_EQ(out[0].op, binlog::STREAM_CHANGE_UPDATE_BEFORE);
+ EXPECT_EQ(out[0].val, 10); // before
+ EXPECT_EQ(out[1].op, binlog::STREAM_CHANGE_UPDATE_AFTER);
+ EXPECT_EQ(out[1].val, 20); // after
+}
+
+// Mixed ops emitted verbatim in order.
+TEST_F(BlockReaderChangeNextBlockTest, DetailMixedOps) {
+ auto source = make_source_block({
+ {1, 10, 0, 1, 1, ROW_BINLOG_APPEND},
+ {2, 25, 20, 2, 2, ROW_BINLOG_UPDATE},
+ {3, 30, 30, 3, 3, ROW_BINLOG_DELETE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 16);
+
+ auto out = drain(reader, &BlockReader::_detail_change_next_block);
+ ASSERT_EQ(out.size(), 4); // INSERT + (BEFORE,AFTER) + DELETE
+ EXPECT_EQ(out[0].op, binlog::STREAM_CHANGE_INSERT);
+ EXPECT_EQ(out[0].val, 10);
+ EXPECT_EQ(out[1].op, binlog::STREAM_CHANGE_UPDATE_BEFORE);
+ EXPECT_EQ(out[1].val, 20);
+ EXPECT_EQ(out[2].op, binlog::STREAM_CHANGE_UPDATE_AFTER);
+ EXPECT_EQ(out[2].val, 25);
+ EXPECT_EQ(out[3].op, binlog::STREAM_CHANGE_DELETE);
+ EXPECT_EQ(out[3].val, 30);
+}
+
+// UPDATE whose BEFORE row fills the batch: AFTER is parked in
_pending_row_columns
+// and flushed at the start of the next call.
+TEST_F(BlockReaderChangeNextBlockTest, DetailUpdatePendingRowAcrossBatch) {
+ auto source = make_source_block({
+ {1, 10, 0, 1, 1, ROW_BINLOG_APPEND},
+ {2, 25, 20, 2, 2, ROW_BINLOG_UPDATE},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, /*batch_size=*/2);
+
+ bool eof = false;
+ Block block1 = make_output_block();
+ ASSERT_TRUE(reader._detail_change_next_block(&block1, &eof).ok());
+ ASSERT_EQ(block1.rows(), 2);
+ EXPECT_EQ(out_i64(block1, OP_IDX, 0), binlog::STREAM_CHANGE_INSERT);
+ EXPECT_EQ(out_i64(block1, OP_IDX, 1), binlog::STREAM_CHANGE_UPDATE_BEFORE);
+ EXPECT_EQ(out_i64(block1, VAL_IDX, 1), 20); // before
+ EXPECT_FALSE(eof);
+ EXPECT_TRUE(reader._has_pending_row);
+
+ Block block2 = make_output_block();
+ ASSERT_TRUE(reader._detail_change_next_block(&block2, &eof).ok());
+ ASSERT_EQ(block2.rows(), 1);
+ EXPECT_EQ(out_i64(block2, OP_IDX, 0), binlog::STREAM_CHANGE_UPDATE_AFTER);
+ EXPECT_EQ(out_i64(block2, VAL_IDX, 0), 25); // after
+ EXPECT_TRUE(eof);
+}
+
+// Already-at-EOF with no pending row returns eof immediately with no output.
+TEST_F(BlockReaderChangeNextBlockTest, DetailEofImmediately) {
+ auto source = make_source_block({
+ {1, 10, 0, 1, 1, ROW_BINLOG_APPEND},
+ });
+ BlockReader reader;
+ configure_reader(reader, source, 16);
+
+ // First call drains the only row.
+ auto out = drain(reader, &BlockReader::_detail_change_next_block);
+ ASSERT_EQ(out.size(), 1);
+
+ // A subsequent call should report eof with no rows.
+ bool eof = false;
+ Block block = make_output_block();
+ ASSERT_TRUE(reader._detail_change_next_block(&block, &eof).ok());
+ EXPECT_EQ(block.rows(), 0);
+ EXPECT_TRUE(eof);
+}
+
+} // namespace doris
diff --git
a/regression-test/data/table_stream_p0/test_mow_min_delta_delete_before.out
b/regression-test/data/table_stream_p0/test_mow_min_delta_delete_before.out
new file mode 100644
index 00000000000..511584038f6
--- /dev/null
+++ b/regression-test/data/table_stream_p0/test_mow_min_delta_delete_before.out
@@ -0,0 +1,7 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !mow_min_delta_delete_before --
+1 10 DELETE
+
+-- !mow_min_delta_del_ins_del --
+2 20 DELETE
+
diff --git
a/regression-test/data/table_stream_p0/test_olap_table_stream_mixed_history_incremental.out
b/regression-test/data/table_stream_p0/test_olap_table_stream_mixed_history_incremental.out
index 8875bf944a5..0011e50ed8b 100644
---
a/regression-test/data/table_stream_p0/test_olap_table_stream_mixed_history_incremental.out
+++
b/regression-test/data/table_stream_p0/test_olap_table_stream_mixed_history_incremental.out
@@ -6,3 +6,4 @@
-- !mixed_history_incremental_count --
1 1
11 1
+
diff --git
a/regression-test/data/table_stream_p0/test_table_stream_query_comprehensive.out
b/regression-test/data/table_stream_p0/test_table_stream_query_comprehensive.out
index 65645ba2bde..e72e41e5b90 100644
---
a/regression-test/data/table_stream_p0/test_table_stream_query_comprehensive.out
+++
b/regression-test/data/table_stream_p0/test_table_stream_query_comprehensive.out
@@ -36,3 +36,4 @@
2 p2b
3 p3
3 p3b
+
diff --git
a/regression-test/suites/table_stream_p0/test_min_delta_stream.groovy
b/regression-test/suites/table_stream_p0/test_min_delta_stream.groovy
index 634367f14d4..bd85855cd79 100644
--- a/regression-test/suites/table_stream_p0/test_min_delta_stream.groovy
+++ b/regression-test/suites/table_stream_p0/test_min_delta_stream.groovy
@@ -473,7 +473,7 @@ suite("test_min_delta_stream", "nonConcurrent") {
"""
assertEquals(1, ukDeleteBeforeRows.size())
assertEquals("10", ukDeleteBeforeRows[0][0].toString())
- assertEquals("101", ukDeleteBeforeRows[0][1].toString())
+ assertEquals("100", ukDeleteBeforeRows[0][1].toString())
assertEquals("DELETE", ukDeleteBeforeRows[0][2].toString())
// 9) show_initial_rows=true:
diff --git
a/regression-test/suites/table_stream_p0/test_mow_min_delta_delete_before.groovy
b/regression-test/suites/table_stream_p0/test_mow_min_delta_delete_before.groovy
new file mode 100644
index 00000000000..8b9633d3a21
--- /dev/null
+++
b/regression-test/suites/table_stream_p0/test_mow_min_delta_delete_before.groovy
@@ -0,0 +1,125 @@
+// 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_mow_min_delta_delete_before", "nonConcurrent") {
+ if (isCloudMode()) {
+ return
+ }
+ sql "DROP DATABASE IF EXISTS test_mow_min_delta_delete_before_db"
+ sql "CREATE DATABASE test_mow_min_delta_delete_before_db"
+ sql "USE test_mow_min_delta_delete_before_db"
+ sql "set enable_nereids_planner=true"
+ sql "set enable_fallback_to_original_planner=false"
+
+ try {
+ sql "DROP STREAM IF EXISTS mow_min_delta_bug_stream"
+ sql "DROP TABLE IF EXISTS mow_min_delta_bug"
+
+ // MoW UNIQUE KEY + MIN_DELTA: when an UPDATE and a DELETE on the same
key
+ // are folded into a single DELETE, the emitted DELETE row must carry
the
+ // pre-delete snapshot (the UPDATE_BEFORE old value), not the
UPDATE_AFTER
+ // new value nor the DELETE tombstone value.
+ sql """
+ CREATE TABLE mow_min_delta_bug (
+ id BIGINT,
+ v1 INT
+ ) ENGINE=OLAP
+ UNIQUE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+ // Seed old value before the stream starts so the in-window change is
an
+ // UPDATE (not a fresh APPEND).
+ sql "INSERT INTO mow_min_delta_bug VALUES (1, 10)"
+ sql """
+ CREATE STREAM mow_min_delta_bug_stream
+ ON TABLE mow_min_delta_bug
+ PROPERTIES (
+ "type" = "min_delta",
+ "show_initial_rows" = "false"
+ )
+ """
+ sql "sync"
+ sql "INSERT INTO mow_min_delta_bug VALUES (1, 11)"
+ sql "DELETE FROM mow_min_delta_bug WHERE id = 1"
+ sql "sync"
+ sleep(1200)
+
+ // Expect a single DELETE row keeping the old value 10.
+ order_qt_mow_min_delta_delete_before """
+ SELECT id, v1, __DORIS_STREAM_CHANGE_TYPE_COL__
+ FROM mow_min_delta_bug_stream
+ ORDER BY id, v1, __DORIS_STREAM_CHANGE_TYPE_COL__
+ """
+
+ sql "DROP STREAM IF EXISTS mow_min_delta_del_ins_del_stream"
+ sql "DROP TABLE IF EXISTS mow_min_delta_del_ins_del"
+
+ // MoW UNIQUE KEY + MIN_DELTA: when a DELETE, a re-INSERT and another
DELETE
+ // on the same key are folded into a single DELETE, the first op is a
DELETE,
+ // which means the key already existed before the window. The emitted
DELETE
+ // row must carry the first op's pre-delete snapshot (the original
value 20),
+ // not the re-inserted value 21.
+ sql """
+ CREATE TABLE mow_min_delta_del_ins_del (
+ id BIGINT,
+ v1 INT
+ ) ENGINE=OLAP
+ UNIQUE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+ // Seed old value before the stream starts so the first in-window
DELETE
+ // refers to a pre-existing row.
+ sql "INSERT INTO mow_min_delta_del_ins_del VALUES (2, 20)"
+ sql """
+ CREATE STREAM mow_min_delta_del_ins_del_stream
+ ON TABLE mow_min_delta_del_ins_del
+ PROPERTIES (
+ "type" = "min_delta",
+ "show_initial_rows" = "false"
+ )
+ """
+ sql "sync"
+ sql "DELETE FROM mow_min_delta_del_ins_del WHERE id = 2"
+ sql "INSERT INTO mow_min_delta_del_ins_del VALUES (2, 21)"
+ sql "DELETE FROM mow_min_delta_del_ins_del WHERE id = 2"
+ sql "sync"
+ sleep(1200)
+
+ // Expect a single DELETE row keeping the old value 20.
+ order_qt_mow_min_delta_del_ins_del """
+ SELECT id, v1, __DORIS_STREAM_CHANGE_TYPE_COL__
+ FROM mow_min_delta_del_ins_del_stream
+ ORDER BY id, v1, __DORIS_STREAM_CHANGE_TYPE_COL__
+ """
+ } finally {
+ sql "DROP DATABASE IF EXISTS test_mow_min_delta_delete_before_db"
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]