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 efb70b32966 [fix](table stream) force binlog tso pred pushdown and 
forbid other pred pushdown (#65973)
efb70b32966 is described below

commit efb70b3296692fec8594b32df12ab070e810b661
Author: TsukiokaKogane <[email protected]>
AuthorDate: Wed Jul 29 19:59:10 2026 +0800

    [fix](table stream) force binlog tso pred pushdown and forbid other pred 
pushdown (#65973)
    
    ### What problem does this PR solve?
    
    Issue Number: close #65705 #65909
    
    Problem Summary:
    
    Table Stream queries may return incorrect results when predicates are
    pushed down during `MIN_DELTA` or `DETAIL` row-binlog scans.
    
    The root cause is that a row-binlog scan does not have the same row
    semantics as its final Table Stream output:
    
    1. A binlog record may contain both current values and before-image
    values.
    2. The raw binlog operation is different from the final stream
    operation. For example, one raw UPDATE operation may produce
    `UPDATE_BEFORE` and `UPDATE_AFTER` stream rows.
    3. In `MIN_DELTA` and `DETAIL` modes, `BlockReader` may merge multiple
    records or split one stored binlog row into multiple output rows.
    
    Therefore, predicates on value columns or the binlog operation column
    must not be evaluated by `SegmentIterator` before the binlog merge/split
    step. At that point, the values and operation do not yet represent the
    final stream rows. Early predicate evaluation can remove the raw row
    needed to generate a valid `DELETE`, `UPDATE_BEFORE`, or `UPDATE_AFTER`
    result.
    
    This PR keeps non-key predicates as residual predicates above
    `BlockReader` for `MIN_DELTA` and `DETAIL` scans. Key-column predicates
    remain safe to push down because the key is unchanged by the binlog
    transformation.
    
    The TSO range predicate is a special case and must still be applied
    before `BlockReader` performs MIN_DELTA grouping and aggregation. If
    rows outside `(start_tso, end_tso]` reach the same-key group, they can
    change the merged result and produce incorrect incremental output.
    However, the normal TabletReader key/value predicate split does not
    guarantee that the TSO predicate reaches `SegmentIterator`.
    
    To guarantee correctness, this PR passes the TSO range through:
    
    `OlapScanner -> TabletReader::ReaderParams -> RowsetReaderContext ->
    BetaRowsetReader`
    
    `BetaRowsetReader` then creates the TSO comparison predicates and
    injects them directly into `StorageReadOptions`. This guarantees that
    the TSO range is applied at the storage layer while all predicates that
    depend on the final stream-row semantics remain above the binlog
    merge/split operation.
    
    The resulting behavior is:
    
    - TSO predicates are always pushed down to restrict the raw binlog
    input.
    - Key-column predicates may still be pushed down.
    - Value-column and operation-dependent predicates are evaluated only
    after `MIN_DELTA` or `DETAIL` processing has produced the final stream
    rows.
---
 be/src/exec/operator/olap_scan_operator.cpp        |   7 +
 be/src/exec/operator/olap_scan_operator.h          |   4 +
 be/src/exec/operator/scan_operator.cpp             |   2 +-
 be/src/exec/operator/scan_operator.h               |   4 +
 be/src/exec/scan/olap_scanner.cpp                  |  41 ++---
 be/src/exec/scan/olap_scanner.h                    |   2 +-
 be/src/storage/rowset/beta_rowset_reader.cpp       |  43 ++++-
 be/src/storage/rowset/rowset_reader_context.h      |   4 +
 be/src/storage/tablet/tablet_reader.cpp            |   2 +
 be/src/storage/tablet/tablet_reader.h              |   5 +
 be/test/exec/operator/olap_scan_operator_test.cpp  |  96 ++++++++++
 be/test/storage/rowset/beta_rowset_reader_test.cpp | 192 ++++++++++++++++++++
 be/test/storage/tablet_reader_test.cpp             |  53 ++++++
 .../doris/nereids/rules/analysis/BindRelation.java |  50 +++---
 .../plans/logical/LogicalOlapTableStreamScan.java  |  13 +-
 .../trees/plans/ExplainTableStreamPlanTest.java    | 125 ++++++++++++-
 .../test_min_delta_op_filter_correctness.out       |  68 +++++++
 .../test_min_delta_op_filter_correctness.groovy    | 196 +++++++++++++++++++++
 18 files changed, 849 insertions(+), 58 deletions(-)

diff --git a/be/src/exec/operator/olap_scan_operator.cpp 
b/be/src/exec/operator/olap_scan_operator.cpp
index 50d41d3dcc9..7a346f12152 100644
--- a/be/src/exec/operator/olap_scan_operator.cpp
+++ b/be/src/exec/operator/olap_scan_operator.cpp
@@ -512,6 +512,13 @@ bool OlapScanLocalState::_is_key_column(const std::string& 
key_name) {
     return res != p._olap_scan_node.key_column_name.end();
 }
 
+bool OlapScanLocalState::can_push_down_column_predicate(const SlotDescriptor* 
slot) {
+    // The Operator-level method handles static column capabilities. The 
LocalState-level
+    // condition additionally handles the current scan range's binlog merge 
mode.
+    return Base::can_push_down_column_predicate(slot) &&
+           (!_is_binlog_merge_scan() || _is_key_column(slot->col_name()));
+}
+
 Status OlapScanLocalState::_should_push_down_function_filter(VectorizedFnCall* 
fn_call,
                                                              VExprContext* 
expr_ctx,
                                                              StringRef* 
constant_str,
diff --git a/be/src/exec/operator/olap_scan_operator.h 
b/be/src/exec/operator/olap_scan_operator.h
index 1d2d2015039..9f7ddca0527 100644
--- a/be/src/exec/operator/olap_scan_operator.h
+++ b/be/src/exec/operator/olap_scan_operator.h
@@ -79,6 +79,8 @@ private:
     Status _process_conjuncts(RuntimeState* state) override;
     bool _is_key_column(const std::string& col_name) override;
 
+    bool can_push_down_column_predicate(const SlotDescriptor* slot) override;
+
     Status _should_push_down_function_filter(VectorizedFnCall* fn_call, 
VExprContext* expr_ctx,
                                              StringRef* constant_str,
                                              doris::FunctionContext** fn_ctx,
@@ -87,6 +89,7 @@ private:
     PushDownType _should_push_down_bloom_filter() const override {
         return PushDownType::ACCEPTABLE;
     }
+
     PushDownType _should_push_down_topn_filter() const override { return 
PushDownType::ACCEPTABLE; }
 
     PushDownType _should_push_down_is_null_predicate(VectorizedFnCall* 
fn_call) const override {
@@ -95,6 +98,7 @@ private:
                        ? PushDownType::ACCEPTABLE
                        : PushDownType::UNACCEPTABLE;
     }
+
     PushDownType _should_push_down_in_predicate() const override {
         return PushDownType::ACCEPTABLE;
     }
diff --git a/be/src/exec/operator/scan_operator.cpp 
b/be/src/exec/operator/scan_operator.cpp
index 3bb234d16b1..e1aff774e7a 100644
--- a/be/src/exec/operator/scan_operator.cpp
+++ b/be/src/exec/operator/scan_operator.cpp
@@ -619,7 +619,7 @@ bool 
ScanLocalState<Derived>::_is_predicate_acting_on_slot(const VExprSPtrs& chi
     if (_slot_id_to_value_range.end() == sid_to_range) {
         return false;
     }
-    if (!_parent->cast<typename 
Derived::Parent>().can_push_down_column_predicate(*slot_desc)) {
+    if (!can_push_down_column_predicate(*slot_desc)) {
         return false;
     }
     *range = &(sid_to_range->second);
diff --git a/be/src/exec/operator/scan_operator.h 
b/be/src/exec/operator/scan_operator.h
index ae93e155852..e11eb506a77 100644
--- a/be/src/exec/operator/scan_operator.h
+++ b/be/src/exec/operator/scan_operator.h
@@ -295,6 +295,10 @@ protected:
     }
     virtual bool _should_push_down_common_expr(const VExprSPtr&) { return 
false; }
 
+    virtual bool can_push_down_column_predicate(const SlotDescriptor* slot) {
+        return _parent->cast<typename 
Derived::Parent>().can_push_down_column_predicate(slot);
+    }
+
     virtual bool _storage_no_merge() { return false; }
     virtual bool _is_key_column(const std::string& col_name) { return false; }
 
diff --git a/be/src/exec/scan/olap_scanner.cpp 
b/be/src/exec/scan/olap_scanner.cpp
index 834ee012737..fee0f1b6199 100644
--- a/be/src/exec/scan/olap_scanner.cpp
+++ b/be/src/exec/scan/olap_scanner.cpp
@@ -60,7 +60,6 @@
 #include "storage/olap_common.h"
 #include "storage/olap_tuple.h"
 #include "storage/olap_utils.h"
-#include "storage/predicate/predicate_creator.h"
 #include "storage/storage_engine.h"
 #include "storage/tablet/tablet_schema.h"
 #ifndef NDEBUG
@@ -103,7 +102,9 @@ OlapScanner::OlapScanner(ScanLocalStateBase* parent, 
OlapScanner::Params&& param
                                  .collection_statistics {},
                                  .ann_topn_runtime {},
                                  .condition_cache_digest = 
parent->get_condition_cache_digest(),
-                                 .binlog_scan_type = params.binlog_scan_type}),
+                                 .binlog_scan_type = params.binlog_scan_type,
+                                 .start_tso = std::nullopt,
+                                 .end_tso = std::nullopt}),
           _start_tso(params.start_tso),
           _end_tso(params.end_tso),
           
_initial_file_cache_stats(std::move(params.initial_file_cache_stats)) {
@@ -328,10 +329,11 @@ Status OlapScanner::_open_impl(RuntimeState* state) {
     return Status::OK();
 }
 
-// For binlog partition-based incremental read. Pushes down [start_tso, 
end_tso] range
-// predicates onto the TSO column. If the TSO column is not already returned, 
pass it as
-// a storage-only predicate column instead of widening scan output.
-Status OlapScanner::_init_tso_predicates() {
+// For binlog/snapshot incremental read. Forwards the (start_tso, end_tso] 
range and the TSO
+// column id down to BetaRowsetReader, which builds the comparison predicates 
directly on read
+// options. This bypasses the value/key predicate split in 
TabletReader::_init_conditions_param,
+// guaranteeing the range filter always reaches storage (a correctness 
requirement for MIN_DELTA).
+Status OlapScanner::_init_tso_pushdown() {
     if (!_start_tso.has_value() && !_end_tso.has_value()) {
         return Status::OK();
     }
@@ -348,17 +350,11 @@ Status OlapScanner::_init_tso_predicates() {
                                      column_name);
     }
 
-    auto data_type = std::make_shared<DataTypeInt64>();
-    if (_start_tso.has_value()) {
-        Field start_value = Field::create_field<TYPE_BIGINT>(*_start_tso);
-        
_tablet_reader_params.predicates.push_back(create_comparison_predicate<PredicateType::GT>(
-                tso_index, column_name, data_type, start_value, false));
-    }
-    if (_end_tso.has_value()) {
-        Field end_value = Field::create_field<TYPE_BIGINT>(*_end_tso);
-        
_tablet_reader_params.predicates.push_back(create_comparison_predicate<PredicateType::LE>(
-                tso_index, column_name, data_type, end_value, false));
-    }
+    // Push the TSO range down as-is; BetaRowsetReader builds the comparison 
predicates and
+    // injects them straight into read options, so they cannot be dropped by 
the value/key
+    // predicate split in TabletReader::_init_conditions_param.
+    _tablet_reader_params.start_tso = _start_tso;
+    _tablet_reader_params.end_tso = _end_tso;
 
     // The storage-layer statistics fast path (VStatisticsIterator, picked when
     // push_down_agg_type is COUNT/MINMAX) bypasses SegmentIterator and 
returns raw
@@ -369,11 +365,10 @@ Status OlapScanner::_init_tso_predicates() {
     // the binlog DETAIL/MIN_DELTA handling.
     _tablet_reader_params.push_down_agg_type_opt = TPushAggOp::NONE;
 
-    if (std::find(_tablet_reader_params.return_columns.begin(),
-                  _tablet_reader_params.return_columns.end(),
-                  tso_index) == _tablet_reader_params.return_columns.end()) {
-        _tablet_reader_params.tso_predicate_column_id = 
static_cast<ColumnId>(tso_index);
-    }
+    // Always carry the tso column id so BetaRowsetReader can build predicates 
on it.
+    // Whether the column must be appended to read_columns (because it is not 
in
+    // return_columns) is decided downstream in BetaRowsetReader.
+    _tablet_reader_params.tso_predicate_column_id = 
static_cast<ColumnId>(tso_index);
 
     return Status::OK();
 }
@@ -568,7 +563,7 @@ Status OlapScanner::_init_tablet_reader_params(
         }
     }
 
-    RETURN_IF_ERROR(_init_tso_predicates());
+    RETURN_IF_ERROR(_init_tso_pushdown());
 
     // For any row-binlog scan, force the storage layer to deliver rows 
strictly in primary-key
     // order so the BlockReader can group consecutive same-key changes 
(MIN_DELTA) or emit
diff --git a/be/src/exec/scan/olap_scanner.h b/be/src/exec/scan/olap_scanner.h
index 308a6194756..0802140960e 100644
--- a/be/src/exec/scan/olap_scanner.h
+++ b/be/src/exec/scan/olap_scanner.h
@@ -111,7 +111,7 @@ private:
                     predicates,
             const std::vector<FunctionFilter>& function_filters);
 
-    [[nodiscard]] Status _init_tso_predicates();
+    [[nodiscard]] Status _init_tso_pushdown();
     [[nodiscard]] Status _init_return_columns();
     [[nodiscard]] Status _init_variant_columns();
 #ifndef NDEBUG
diff --git a/be/src/storage/rowset/beta_rowset_reader.cpp 
b/be/src/storage/rowset/beta_rowset_reader.cpp
index 708346e8414..a70708e765e 100644
--- a/be/src/storage/rowset/beta_rowset_reader.cpp
+++ b/be/src/storage/rowset/beta_rowset_reader.cpp
@@ -31,6 +31,7 @@
 #include "common/logging.h"
 #include "common/status.h"
 #include "core/block/block.h"
+#include "core/data_type/data_type_number.h"
 #include "io/io_common.h"
 #include "runtime/descriptors.h"
 #include "runtime/query_context.h"
@@ -41,6 +42,7 @@
 #include "storage/olap_define.h"
 #include "storage/predicate/block_column_predicate.h"
 #include "storage/predicate/column_predicate.h"
+#include "storage/predicate/predicate_creator.h"
 #include "storage/row_cursor.h"
 #include "storage/rowset/rowset_meta.h"
 #include "storage/rowset/rowset_reader_context.h"
@@ -145,12 +147,15 @@ Status 
BetaRowsetReader::get_segment_iterators(RowsetReaderContext* read_context
             read_columns.push_back(cid);
         }
     }
-    if (_read_context->tso_predicate_column_id.has_value()) {
+    if (_read_context->tso_predicate_column_id.has_value() &&
+        read_columns_set.find(*_read_context->tso_predicate_column_id) == 
read_columns_set.end()) {
         read_columns.push_back(*_read_context->tso_predicate_column_id);
     }
-    // disable condition cache if you have delete condition
+    // disable condition cache if you have delete condition or forced pushed 
tso predicate
     _read_context->condition_cache_digest =
-            delete_columns_set.empty() ? _read_context->condition_cache_digest 
: 0;
+            (delete_columns_set.empty() && 
!_read_context->tso_predicate_column_id.has_value())
+                    ? _read_context->condition_cache_digest
+                    : 0;
     // create segment iterators
     VLOG_NOTICE << "read columns size: " << read_columns.size();
     _input_schema = 
std::make_shared<Schema>(_read_context->tablet_schema->columns(), read_columns);
@@ -174,6 +179,38 @@ Status 
BetaRowsetReader::get_segment_iterators(RowsetReaderContext* read_context
         }
     }
 
+    // Forced TSO range pushdown for binlog/snapshot incremental read. Build 
the (start_tso,
+    // end_tso] comparison predicates here and inject them directly, so they 
always reach the
+    // SegmentIterator for row-level filtering instead of going through the 
value/key predicate
+    // split in TabletReader::_init_conditions_param (where they may be 
dropped). This is a
+    // correctness requirement for MIN_DELTA, which groups consecutive 
same-key rows and would
+    // produce wrong results if out-of-range rows leaked into a group.
+    if (_read_context->tso_predicate_column_id.has_value() &&
+        (_read_context->start_tso.has_value() || 
_read_context->end_tso.has_value())) {
+        ColumnId tso_cid = *_read_context->tso_predicate_column_id;
+        auto tso_data_type = std::make_shared<DataTypeInt64>();
+        const std::string& tso_col_name = 
_read_context->tablet_schema->column(tso_cid).name();
+        auto add_tso_predicate = [&](std::shared_ptr<ColumnPredicate> pred) {
+            if (_read_options.col_id_to_predicates.count(pred->column_id()) < 
1) {
+                _read_options.col_id_to_predicates.insert(
+                        {pred->column_id(), 
AndBlockColumnPredicate::create_shared()});
+            }
+            
_read_options.col_id_to_predicates[pred->column_id()]->add_column_predicate(
+                    SingleColumnBlockPredicate::create_unique(pred));
+            _read_options.column_predicates.push_back(std::move(pred));
+        };
+        if (_read_context->start_tso.has_value()) {
+            add_tso_predicate(create_comparison_predicate<PredicateType::GT>(
+                    tso_cid, tso_col_name, tso_data_type,
+                    
Field::create_field<TYPE_BIGINT>(*_read_context->start_tso), false));
+        }
+        if (_read_context->end_tso.has_value()) {
+            add_tso_predicate(create_comparison_predicate<PredicateType::LE>(
+                    tso_cid, tso_col_name, tso_data_type,
+                    Field::create_field<TYPE_BIGINT>(*_read_context->end_tso), 
false));
+        }
+    }
+
     // Take a delete-bitmap for each segment, the bitmap contains all deletes
     // until the max read version, which is read_context->version.second
     if (_read_context->delete_bitmap != nullptr) {
diff --git a/be/src/storage/rowset/rowset_reader_context.h 
b/be/src/storage/rowset/rowset_reader_context.h
index 7d37b03dc2a..18eb442eccc 100644
--- a/be/src/storage/rowset/rowset_reader_context.h
+++ b/be/src/storage/rowset/rowset_reader_context.h
@@ -61,6 +61,10 @@ struct RowsetReaderContext {
     const std::vector<uint32_t>* return_columns = nullptr;
     // TSO predicate column that is absent from return_columns but must be 
read by storage.
     std::optional<ColumnId> tso_predicate_column_id;
+    // Binlog/snapshot incremental read TSO range (start_tso, end_tso]. 
BetaRowsetReader builds
+    // the tso comparison predicates from this range and forces them onto read 
options.
+    std::optional<int64_t> start_tso;
+    std::optional<int64_t> end_tso;
     TPushAggOp::type push_down_agg_type_opt = TPushAggOp::NONE;
     // column name -> column predicate
     // adding column_name for predicate to make use of column selectivity
diff --git a/be/src/storage/tablet/tablet_reader.cpp 
b/be/src/storage/tablet/tablet_reader.cpp
index 3feb0d9009b..f6eadc46ca6 100644
--- a/be/src/storage/tablet/tablet_reader.cpp
+++ b/be/src/storage/tablet/tablet_reader.cpp
@@ -182,6 +182,8 @@ Status TabletReader::_capture_rs_readers(const 
ReaderParams& read_params) {
     _reader_context.read_orderby_key_limit = 
read_params.read_orderby_key_limit;
     _reader_context.return_columns = &_return_columns;
     _reader_context.tso_predicate_column_id = 
read_params.tso_predicate_column_id;
+    _reader_context.start_tso = read_params.start_tso;
+    _reader_context.end_tso = read_params.end_tso;
     _reader_context.read_orderby_key_columns =
             !_orderby_key_columns.empty() ? &_orderby_key_columns : nullptr;
     _reader_context.predicates = &_col_predicates;
diff --git a/be/src/storage/tablet/tablet_reader.h 
b/be/src/storage/tablet/tablet_reader.h
index b916721588a..771588e9c93 100644
--- a/be/src/storage/tablet/tablet_reader.h
+++ b/be/src/storage/tablet/tablet_reader.h
@@ -228,6 +228,11 @@ public:
         // General LIMIT budget forwarded to SegmentIterator. -1 means no 
limit.
         int64_t general_read_limit = -1;
         TBinlogScanType::type binlog_scan_type = TBinlogScanType::NONE;
+        // Binlog/snapshot incremental read TSO range (start_tso, end_tso]. 
Forced down to
+        // BetaRowsetReader, which builds the tso predicates directly on read 
options,
+        // bypassing the value/key predicate split in _init_conditions_param.
+        std::optional<int64_t> start_tso;
+        std::optional<int64_t> end_tso;
     };
 
     TabletReader() = default;
diff --git a/be/test/exec/operator/olap_scan_operator_test.cpp 
b/be/test/exec/operator/olap_scan_operator_test.cpp
new file mode 100644
index 00000000000..bc66290106d
--- /dev/null
+++ b/be/test/exec/operator/olap_scan_operator_test.cpp
@@ -0,0 +1,96 @@
+// 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.
+
+#include "exec/operator/olap_scan_operator.h"
+
+#include <gtest/gtest.h>
+
+#include <memory>
+
+#include "common/object_pool.h"
+#include "core/data_type/data_type_number.h"
+#include "gen_cpp/PlanNodes_types.h"
+#include "gen_cpp/QueryCache_types.h"
+#include "testutil/desc_tbl_builder.h"
+#include "testutil/mock/mock_runtime_state.h"
+
+namespace doris {
+
+class OlapScanOperatorBinlogPushDownTest : public testing::Test {
+protected:
+    void SetUp() override {
+        _state = std::make_shared<MockRuntimeState>();
+
+        DescriptorTblBuilder desc_builder(&_pool);
+        desc_builder.declare_tuple()
+                << TupleDescBuilder::SlotType 
{std::make_shared<DataTypeInt32>(), "k1"}
+                << TupleDescBuilder::SlotType 
{std::make_shared<DataTypeInt32>(), "v1"};
+        _descs = desc_builder.build();
+        ASSERT_NE(_descs, nullptr);
+
+        auto* tuple_desc = _descs->get_tuple_descriptor(0);
+        ASSERT_NE(tuple_desc, nullptr);
+        ASSERT_EQ(tuple_desc->slots().size(), 2);
+        _key_slot = tuple_desc->slots()[0];
+        _value_slot = tuple_desc->slots()[1];
+
+        TOlapScanNode olap_scan_node;
+        olap_scan_node.__set_tuple_id(0);
+        olap_scan_node.__set_keyType(TKeysType::UNIQUE_KEYS);
+        olap_scan_node.__set_key_column_name({"k1"});
+        olap_scan_node.__set_key_column_type({TPrimitiveType::INT});
+
+        TPlanNode plan_node;
+        plan_node.__set_node_id(0);
+        plan_node.__set_node_type(TPlanNodeType::OLAP_SCAN_NODE);
+        plan_node.__set_num_children(0);
+        plan_node.__set_row_tuples({0});
+        plan_node.__set_olap_scan_node(olap_scan_node);
+
+        _parent = std::make_shared<OlapScanOperatorX>(&_pool, plan_node, 0, 
*_descs, 1,
+                                                      TQueryCacheParam {});
+        _local_state = OlapScanLocalState::create_shared(_state.get(), 
_parent.get());
+
+        auto range = std::make_unique<TPaloScanRange>();
+        range->__set_binlog_scan_type(TBinlogScanType::MIN_DELTA);
+        _local_state->_scan_ranges.push_back(std::move(range));
+        ASSERT_TRUE(_local_state->_is_binlog_merge_scan());
+    }
+
+    ObjectPool _pool;
+    DescriptorTbl* _descs = nullptr;
+    SlotDescriptor* _key_slot = nullptr;
+    SlotDescriptor* _value_slot = nullptr;
+    std::shared_ptr<MockRuntimeState> _state;
+    std::shared_ptr<OlapScanOperatorX> _parent;
+    std::shared_ptr<OlapScanLocalState> _local_state;
+};
+
+TEST_F(OlapScanOperatorBinlogPushDownTest, MergeScanPushesDownOnlyKeyColumns) {
+    EXPECT_TRUE(_local_state->can_push_down_column_predicate(_key_slot));
+    EXPECT_FALSE(_local_state->can_push_down_column_predicate(_value_slot));
+}
+
+TEST_F(OlapScanOperatorBinlogPushDownTest, 
AppendOnlyKeepsValuePredicatePushDown) {
+    
_local_state->_scan_ranges[0]->__set_binlog_scan_type(TBinlogScanType::APPEND_ONLY);
+    ASSERT_FALSE(_local_state->_is_binlog_merge_scan());
+
+    EXPECT_TRUE(_local_state->can_push_down_column_predicate(_key_slot));
+    EXPECT_TRUE(_local_state->can_push_down_column_predicate(_value_slot));
+}
+
+} // namespace doris
diff --git a/be/test/storage/rowset/beta_rowset_reader_test.cpp 
b/be/test/storage/rowset/beta_rowset_reader_test.cpp
new file mode 100644
index 00000000000..a85c8cadfa3
--- /dev/null
+++ b/be/test/storage/rowset/beta_rowset_reader_test.cpp
@@ -0,0 +1,192 @@
+// 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.
+
+#include "storage/rowset/beta_rowset_reader.h"
+
+#include <gen_cpp/olap_file.pb.h>
+#include <gtest/gtest.h>
+
+#include <memory>
+#include <vector>
+
+#include "storage/predicate/column_predicate.h"
+#include "storage/rowset/beta_rowset.h"
+#include "storage/rowset/rowset_meta.h"
+#include "storage/rowset/rowset_reader_context.h"
+#include "storage/tablet/tablet_schema.h"
+
+namespace doris {
+
+// Verifies the forced TSO range pushdown in 
BetaRowsetReader::get_segment_iterators (introduced
+// for binlog/snapshot incremental read). The (start_tso, end_tso] comparison 
predicates and the
+// tso predicate column must be injected directly onto read options, and the 
tso column must be
+// appended to read_columns only when it is not already in return_columns.
+//
+// The rowset here has zero segments, so get_segment_iterators skips segment 
iterator creation
+// and returns after the injection logic, which is exactly the code path under 
test.
+class BetaRowsetReaderTsoInjectionTest : public testing::Test {
+protected:
+    // Schema: (k1 int key, __binlog_tso__ bigint). Column index 1 is used as 
the tso column.
+    static constexpr ColumnId kTsoColumnId = 1;
+
+    void SetUp() override {
+        _tablet_schema = std::make_shared<TabletSchema>();
+        TabletSchemaPB schema_pb;
+        schema_pb.set_keys_type(DUP_KEYS);
+        schema_pb.set_num_short_key_columns(1);
+        schema_pb.set_num_rows_per_row_block(1024);
+        schema_pb.set_next_column_unique_id(3);
+
+        ColumnPB* key = schema_pb.add_column();
+        key->set_unique_id(1);
+        key->set_name("k1");
+        key->set_type("INT");
+        key->set_is_key(true);
+        key->set_length(4);
+        key->set_index_length(4);
+        key->set_is_nullable(false);
+
+        ColumnPB* tso = schema_pb.add_column();
+        tso->set_unique_id(2);
+        tso->set_name("__binlog_tso__");
+        tso->set_type("BIGINT");
+        tso->set_is_key(false);
+        tso->set_length(8);
+        tso->set_is_nullable(false);
+
+        _tablet_schema->init_from_pb(schema_pb);
+
+        _rowset_meta = std::make_shared<RowsetMeta>();
+        RowsetMetaPB rowset_meta_pb;
+        rowset_meta_pb.set_rowset_id(10001);
+        rowset_meta_pb.set_tablet_id(1);
+        rowset_meta_pb.set_rowset_type(BETA_ROWSET);
+        rowset_meta_pb.set_rowset_state(VISIBLE);
+        rowset_meta_pb.set_start_version(2);
+        rowset_meta_pb.set_end_version(2);
+        rowset_meta_pb.set_num_segments(0);
+        rowset_meta_pb.set_num_rows(0);
+        rowset_meta_pb.set_empty(true);
+        _rowset_meta->init_from_pb(rowset_meta_pb);
+
+        _rowset = std::make_shared<BetaRowset>(_tablet_schema, _rowset_meta, 
"");
+        _reader = std::make_shared<BetaRowsetReader>(_rowset);
+    }
+
+    RowsetReaderContext make_context() {
+        RowsetReaderContext ctx;
+        ctx.tablet_schema = _tablet_schema;
+        ctx.return_columns = &_return_columns;
+        ctx.need_ordered_result = false;
+        return ctx;
+    }
+
+    // Counts predicates that act on the tso column in the injected read 
options.
+    static int count_tso_predicates(const BetaRowsetReader& reader) {
+        int count = 0;
+        for (const auto& pred : reader._read_options.column_predicates) {
+            if (pred->column_id() == kTsoColumnId) {
+                ++count;
+            }
+        }
+        return count;
+    }
+
+    TabletSchemaSPtr _tablet_schema;
+    RowsetMetaSharedPtr _rowset_meta;
+    BetaRowsetSharedPtr _rowset;
+    std::shared_ptr<BetaRowsetReader> _reader;
+    std::vector<uint32_t> _return_columns = {0};
+};
+
+TEST_F(BetaRowsetReaderTsoInjectionTest, InjectBothStartAndEndTso) {
+    RowsetReaderContext ctx = make_context();
+    ctx.tso_predicate_column_id = kTsoColumnId;
+    ctx.start_tso = 100;
+    ctx.end_tso = 200;
+
+    std::vector<RowwiseIteratorUPtr> iters;
+    ASSERT_TRUE(_reader->get_segment_iterators(&ctx, &iters).ok());
+
+    // Both GT(start) and LE(end) predicates are injected on the tso column.
+    EXPECT_EQ(count_tso_predicates(*_reader), 2);
+    EXPECT_EQ(_reader->_read_options.col_id_to_predicates.count(kTsoColumnId), 
1);
+}
+
+TEST_F(BetaRowsetReaderTsoInjectionTest, InjectOnlyStartTso) {
+    RowsetReaderContext ctx = make_context();
+    ctx.tso_predicate_column_id = kTsoColumnId;
+    ctx.start_tso = 100;
+
+    std::vector<RowwiseIteratorUPtr> iters;
+    ASSERT_TRUE(_reader->get_segment_iterators(&ctx, &iters).ok());
+
+    EXPECT_EQ(count_tso_predicates(*_reader), 1);
+    EXPECT_EQ(_reader->_read_options.col_id_to_predicates.count(kTsoColumnId), 
1);
+}
+
+TEST_F(BetaRowsetReaderTsoInjectionTest, InjectOnlyEndTso) {
+    RowsetReaderContext ctx = make_context();
+    ctx.tso_predicate_column_id = kTsoColumnId;
+    ctx.end_tso = 200;
+
+    std::vector<RowwiseIteratorUPtr> iters;
+    ASSERT_TRUE(_reader->get_segment_iterators(&ctx, &iters).ok());
+
+    EXPECT_EQ(count_tso_predicates(*_reader), 1);
+    EXPECT_EQ(_reader->_read_options.col_id_to_predicates.count(kTsoColumnId), 
1);
+}
+
+TEST_F(BetaRowsetReaderTsoInjectionTest, NoInjectionWhenTsoRangeAbsent) {
+    RowsetReaderContext ctx = make_context();
+    // Column id present but neither start nor end tso set: nothing should be 
injected.
+    ctx.tso_predicate_column_id = kTsoColumnId;
+
+    std::vector<RowwiseIteratorUPtr> iters;
+    ASSERT_TRUE(_reader->get_segment_iterators(&ctx, &iters).ok());
+
+    EXPECT_EQ(count_tso_predicates(*_reader), 0);
+}
+
+TEST_F(BetaRowsetReaderTsoInjectionTest, 
AppendTsoColumnWhenNotInReturnColumns) {
+    RowsetReaderContext ctx = make_context();
+    // return_columns only has k1 (id 0), tso column (id 1) must be appended 
to read columns.
+    ctx.tso_predicate_column_id = kTsoColumnId;
+    ctx.start_tso = 100;
+
+    std::vector<RowwiseIteratorUPtr> iters;
+    ASSERT_TRUE(_reader->get_segment_iterators(&ctx, &iters).ok());
+
+    // input schema (read columns) = return_columns + appended tso column = 2 
columns.
+    EXPECT_EQ(_reader->_input_schema->num_column_ids(), 2);
+}
+
+TEST_F(BetaRowsetReaderTsoInjectionTest, 
NotAppendTsoColumnWhenAlreadyInReturnColumns) {
+    RowsetReaderContext ctx = make_context();
+    // tso column already selected in return_columns: it must not be appended 
twice.
+    std::vector<uint32_t> return_columns = {0, kTsoColumnId};
+    ctx.return_columns = &return_columns;
+    ctx.tso_predicate_column_id = kTsoColumnId;
+    ctx.start_tso = 100;
+
+    std::vector<RowwiseIteratorUPtr> iters;
+    ASSERT_TRUE(_reader->get_segment_iterators(&ctx, &iters).ok());
+
+    EXPECT_EQ(_reader->_input_schema->num_column_ids(), 2);
+}
+
+} // namespace doris
diff --git a/be/test/storage/tablet_reader_test.cpp 
b/be/test/storage/tablet_reader_test.cpp
index 6c7db4fcb0c..ef8e9454122 100644
--- a/be/test/storage/tablet_reader_test.cpp
+++ b/be/test/storage/tablet_reader_test.cpp
@@ -128,4 +128,57 @@ TEST_F(TabletReaderTest, 
remove_delete_columns_keeps_unrelated_paths) {
     EXPECT_EQ(size_t(2), access_paths.size());
 }
 
+// Contract test for the binlog/snapshot incremental-read TSO range forwarding 
added to
+// TabletReader::_capture_rs_readers (tablet_reader.cpp:184-186):
+//   _reader_context.start_tso = read_params.start_tso;
+//   _reader_context.end_tso   = read_params.end_tso;
+// Exercising _capture_rs_readers end to end would require a fully constructed 
Tablet + rowset
+// readers (it unconditionally dereferences _tablet), which is far too heavy 
and brittle for a
+// unit test. Instead we pin down the field contract on both sides: both must 
be
+// std::optional<int64_t> defaulting to nullopt, and the forwarding must 
preserve the optional
+// state (both set / only one / none). This guards against the fields being 
dropped or their
+// type changed, which would silently break the forwarding.
+TEST_F(TabletReaderTest, forward_tso_range_field_contract) {
+    // Both sides default to nullopt.
+    TabletReader::ReaderParams params;
+    EXPECT_FALSE(params.start_tso.has_value());
+    EXPECT_FALSE(params.end_tso.has_value());
+
+    RowsetReaderContext default_ctx;
+    EXPECT_FALSE(default_ctx.start_tso.has_value());
+    EXPECT_FALSE(default_ctx.end_tso.has_value());
+
+    // Equivalent of the forwarding assignments; the optional state must be 
preserved verbatim.
+    auto forward = [](const TabletReader::ReaderParams& p) {
+        RowsetReaderContext ctx;
+        ctx.start_tso = p.start_tso;
+        ctx.end_tso = p.end_tso;
+        return ctx;
+    };
+
+    // both set
+    params.start_tso = 100;
+    params.end_tso = 200;
+    RowsetReaderContext ctx = forward(params);
+    ASSERT_TRUE(ctx.start_tso.has_value());
+    ASSERT_TRUE(ctx.end_tso.has_value());
+    EXPECT_EQ(*ctx.start_tso, 100);
+    EXPECT_EQ(*ctx.end_tso, 200);
+
+    // only start set
+    params.start_tso = 100;
+    params.end_tso = std::nullopt;
+    ctx = forward(params);
+    ASSERT_TRUE(ctx.start_tso.has_value());
+    EXPECT_EQ(*ctx.start_tso, 100);
+    EXPECT_FALSE(ctx.end_tso.has_value());
+
+    // none set
+    params.start_tso = std::nullopt;
+    params.end_tso = std::nullopt;
+    ctx = forward(params);
+    EXPECT_FALSE(ctx.start_tso.has_value());
+    EXPECT_FALSE(ctx.end_tso.has_value());
+}
+
 } // namespace doris
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
index 2348453c52d..73c0bf3c5fe 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/BindRelation.java
@@ -76,7 +76,6 @@ import org.apache.doris.nereids.trees.expressions.Alias;
 import org.apache.doris.nereids.trees.expressions.EqualTo;
 import org.apache.doris.nereids.trees.expressions.ExprId;
 import org.apache.doris.nereids.trees.expressions.Expression;
-import org.apache.doris.nereids.trees.expressions.GreaterThan;
 import org.apache.doris.nereids.trees.expressions.InPredicate;
 import org.apache.doris.nereids.trees.expressions.LessThanEqual;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
@@ -256,7 +255,12 @@ public class BindRelation extends OneAnalysisRuleFactory {
         List<Long> tabletIds = unboundRelation.getTabletIds();
         StreamScanType changeScanType = checkChangeScanCondition((OlapTable) 
table, unboundRelation.getScanParams());
         if (changeScanType != null) {
-            table = new RowBinlogTableWrapper((OlapTable) table);
+            table = new RowBinlogTableWrapper((OlapTable) table,
+                    CollectionUtils.isEmpty(partIds)
+                            ? makeUniformedTimestampRangeMap(((OlapTable) 
table).getPartitionIds(),
+                                    
parseTimestampRange(unboundRelation.getScanParams())) :
+                            makeUniformedTimestampRangeMap(partIds,
+                                    
parseTimestampRange(unboundRelation.getScanParams())));
         } else if (unboundRelation.getScanParams() != null) {
             unboundRelation.getScanParams().validateOlapTable();
         }
@@ -307,8 +311,7 @@ public class BindRelation extends OneAnalysisRuleFactory {
                 throw new AnalysisException(
                         "PREAGGOPEN hint is not supported on @incr 
(change-read) scans.");
             }
-            return checkAndAddChangeScanFilter(scan, changeScanType,
-                    parseTimestampRange(unboundRelation.getScanParams()), 
false);
+            return checkAndAddChangeScanFilter(scan, changeScanType, false);
         }
         // Time-travel (FOR VERSION/TIME AS OF): wrap the scan with a 
__DORIS_COMMIT_TSO_COL__
         // predicate (dup) or a base/binlog union (mow).
@@ -597,7 +600,9 @@ public class BindRelation extends OneAnalysisRuleFactory {
         // right: binlog MIN_DELTA over tso>t1, keep UPDATE_BEFORE/DELETE rows 
(before image),
         // projected to the same visible schema. BE splits each change so 
UPDATE_BEFORE/DELETE rows
         // already carry the pre-change value in the (same-named) value 
columns.
-        RowBinlogTableWrapper binlogTable = new 
RowBinlogTableWrapper(olapTable);
+        RowBinlogTableWrapper binlogTable = new 
RowBinlogTableWrapper(olapTable, CollectionUtils.isEmpty(partIds)
+                ? makeUniformedTimestampRangeMap(olapTable.getPartitionIds(), 
Pair.of(targetTso, null)) :
+                makeUniformedTimestampRangeMap(partIds, Pair.of(targetTso, 
null)));
         RelationId binlogRelationId = 
cascadesContext.getStatementContext().getNextRelationId();
         LogicalOlapScan binlogScan = CollectionUtils.isEmpty(partIds)
                 ? new LogicalOlapScan(binlogRelationId, binlogTable, 
qualifier, tabletIds,
@@ -610,8 +615,7 @@ public class BindRelation extends OneAnalysisRuleFactory {
         binlogScan = binlogScan.withTableScanParams(
                 new TableScanParams(TableScanParams.INCREMENTAL_READ, 
incrParams, Lists.newArrayList()));
 
-        LogicalPlan right = checkAndAddChangeScanFilter(binlogScan, 
StreamScanType.MIN_DELTA, Pair.of(targetTso, null),
-                true);
+        LogicalPlan right = checkAndAddChangeScanFilter(binlogScan, 
StreamScanType.MIN_DELTA, true);
         right = projectFromOriginSlots(right, visibleOutput);
 
         // both children are bound; BindExpression aligns by position and 
fills the union output.
@@ -1056,38 +1060,25 @@ public class BindRelation extends 
OneAnalysisRuleFactory {
      * Add append only filter on olap scan with changes if need.
      */
     public static LogicalPlan checkAndAddChangeScanFilter(LogicalOlapScan scan,
-                                                          StreamScanType 
scanType,
-                                                          Pair<Long, Long> 
timestampRange, boolean beforeImageOnly) {
+                                                          StreamScanType 
scanType, boolean beforeImageOnly) {
         LogicalPlan plan = scan;
-        Slot timestampSlot = null;
         Slot opSlot = null;
         for (Slot slot : scan.getOutput()) {
-            if (slot.getName().equals(Column.BINLOG_TSO_COL)) {
-                timestampSlot = slot;
-            }
             if (slot.getName().equals(Column.BINLOG_OPERATION_COL)) {
                 opSlot = slot;
-            }
-            if (opSlot != null && timestampSlot != null) {
                 break;
             }
         }
-        List<Expression> conjuncts = Lists.newArrayList();
-        Preconditions.checkArgument(timestampSlot != null, "timestampSlot is 
null");
-        conjuncts.add(new GreaterThan(timestampSlot, new 
BigIntLiteral(timestampRange.first)));
-        if (timestampRange.second != null) {
-            conjuncts.add(new LessThanEqual(timestampSlot, new 
BigIntLiteral(timestampRange.second)));
-        }
         if (scanType.equals(StreamScanType.APPEND_ONLY)) {
             Preconditions.checkArgument(opSlot != null, "opSlot is null");
-            conjuncts.add(new EqualTo(opSlot, new 
BigIntLiteral(BinlogUtils.ROW_BINLOG_APPEND)));
-        }
-        if (beforeImageOnly) {
-            conjuncts.add(new InPredicate(opSlot, ImmutableList.of(
+            return new LogicalFilter<>(ImmutableSet.of(new EqualTo(opSlot,
+                    new BigIntLiteral(BinlogUtils.ROW_BINLOG_APPEND))), plan);
+        } else if (beforeImageOnly) {
+            return new LogicalFilter<>(ImmutableSet.of(new InPredicate(opSlot, 
ImmutableList.of(
                     new BigIntLiteral(BinlogUtils.ROW_BINLOG_DELETE),
-                    new BigIntLiteral(BinlogUtils.ROW_BINLOG_UPDATE_BEFORE))));
+                    new 
BigIntLiteral(BinlogUtils.ROW_BINLOG_UPDATE_BEFORE)))), plan);
         }
-        return new LogicalFilter<>(ImmutableSet.copyOf(conjuncts), plan);
+        return plan;
     }
 
     private LogicalPlan projectFromOriginSlots(LogicalPlan plan, List<Slot> 
wantedSlots) {
@@ -1104,4 +1095,9 @@ public class BindRelation extends OneAnalysisRuleFactory {
         }
         return new LogicalProject<>(project, plan);
     }
+
+    private Map<Long, Pair<Long, Long>> 
makeUniformedTimestampRangeMap(List<Long> partIds,
+                                                                       
Pair<Long, Long> timestampRange) {
+        return partIds.stream().collect(Collectors.toMap(partId -> partId, 
partId -> timestampRange));
+    }
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapTableStreamScan.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapTableStreamScan.java
index 47ba78d7a61..725bf059c55 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapTableStreamScan.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapTableStreamScan.java
@@ -144,7 +144,18 @@ public class LogicalOlapTableStreamScan extends 
LogicalOlapScan {
                 continue;
             }
             Pair<Long, String> key = Pair.of(selectedIndexId, col.getName());
-            Slot slot = cacheSlotWithSlotName.computeIfAbsent(key, k -> 
slotFromColumn.get(index));
+            // For INCREMENTAL / SNAPSHOT reads, non-key value columns are 
materialized from the
+            // base table row-binlog whose after/before value columns are 
always nullable (see
+            // Column.generateAfterValueColumn / generateBeforeValueColumn). 
Declare these value
+            // columns as nullable here so the stream scan output stays 
consistent with the plan
+            // expanded in NormalizeOlapTableStreamScan, otherwise 
AdjustNullable reports a
+            // not-nullable -> nullable conflict. RESET does a full base-table 
scan, so keep its
+            // original nullability.
+            Slot slot = cacheSlotWithSlotName.computeIfAbsent(key, k -> {
+                SlotReference slotRef = slotFromColumn.get(index);
+                boolean forceNullable = readMode != StreamReadMode.RESET && 
!baseSchema.get(index).isKey();
+                return forceNullable ? slotRef.withNullable(true) : slotRef;
+            });
             slots.add(slot);
             if (colToSubPathsMap.containsKey(key.getValue())) {
                 for (List<String> subPath : 
colToSubPathsMap.get(key.getValue())) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainTableStreamPlanTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainTableStreamPlanTest.java
index edc47a7dce5..52c419556b5 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainTableStreamPlanTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/ExplainTableStreamPlanTest.java
@@ -44,6 +44,7 @@ import org.apache.doris.nereids.parser.NereidsParser;
 import org.apache.doris.nereids.properties.PhysicalProperties;
 import org.apache.doris.nereids.trees.expressions.Alias;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
+import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
 import org.apache.doris.nereids.trees.expressions.StatementScopeIdGenerator;
 import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral;
@@ -61,6 +62,7 @@ import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.thrift.TBinlogScanType;
 import org.apache.doris.thrift.TPaloScanRange;
 import org.apache.doris.thrift.TScanRangeLocations;
+import org.apache.doris.tso.TSOTimestamp;
 import org.apache.doris.utframe.TestWithFeService;
 
 import org.junit.jupiter.api.Assertions;
@@ -90,7 +92,7 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
 
         String createBaseTable = "create table test_stream.tbl_stream_base (\n"
                 + "  k1 int,\n"
-                + "  k2 int\n"
+                + "  k2 int not null\n"
                 + ")\n"
                 + "unique key(k1)\n"
                 + "partition by range(k1)\n"
@@ -123,7 +125,7 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
 
         String createDuplicateBaseTable = "create table 
test_stream.tbl_dup_stream_base (\n"
                 + "  k1 int,\n"
-                + "  k2 int\n"
+                + "  k2 int not null\n"
                 + ")\n"
                 + "duplicate key(k1)\n"
                 + "distributed by hash(k1) buckets 1\n"
@@ -518,6 +520,125 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
         Assertions.assertFalse(prunedScan.hasPartitionPredicate());
     }
 
+    @Test
+    public void testIncrementalStreamScanForcesValueColumnsNullable() throws 
Exception {
+        // s2 is an incremental (INCREMENTAL read mode) stream over a 
unique-key table
+        // (k1 key, k2 value). computeOutput must force non-key value columns 
to nullable so the
+        // scan output stays consistent with the row-binlog after/before value 
columns, while key
+        // columns keep their original nullability (forceNullable is skipped 
for keys).
+        Database db = (Database) 
Env.getCurrentInternalCatalog().getDbOrMetaException("test_stream");
+        OlapTable base = (OlapTable) 
db.getTableOrMetaException("tbl_stream_base");
+        boolean baseK1Nullable = base.getBaseSchema(false).stream()
+                .filter(c -> c.getName().equals("k1"))
+                .findFirst()
+                .orElseThrow()
+                .isAllowNull();
+        boolean baseK2Nullable = base.getBaseSchema(false).stream()
+                .filter(c -> c.getName().equals("k2"))
+                .findFirst()
+                .orElseThrow()
+                .isAllowNull();
+        Assertions.assertFalse(baseK2Nullable,
+                "value column k2 must be NOT NULL in the catalog baseline so 
widening to nullable is provable");
+
+        Plan analyzedPlan = PlanChecker.from(connectContext)
+                .analyze("select * from test_stream.s2")
+                .getCascadesContext()
+                .getRewritePlan();
+
+        LogicalOlapTableStreamScan streamScan = 
findFirstLogicalStreamScan(analyzedPlan);
+        Assertions.assertNotNull(streamScan);
+
+        Slot k1 = findSlot(streamScan, "k1");
+        Slot k2 = findSlot(streamScan, "k2");
+        Assertions.assertNotNull(k1, "key column k1 must be present in stream 
scan output");
+        Assertions.assertNotNull(k2, "value column k2 must be present in 
stream scan output");
+        Assertions.assertEquals(baseK1Nullable, k1.nullable(),
+                "key column k1 must keep its original nullability (not 
force-nullable)");
+        Assertions.assertTrue(k2.nullable(), "non-key value column k2 must be 
forced nullable");
+    }
+
+    @Test
+    public void testResetStreamScanKeepsOriginalNullability() throws Exception 
{
+        // s_dup@reset does a full base-table scan (RESET read mode). 
computeOutput must NOT force
+        // value columns to nullable; the value column keeps the base table's 
original nullability.
+        Database db = (Database) 
Env.getCurrentInternalCatalog().getDbOrMetaException("test_stream");
+        OlapTable dupBase = (OlapTable) 
db.getTableOrMetaException("tbl_dup_stream_base");
+        boolean baseK2Nullable = dupBase.getBaseSchema(false).stream()
+                .filter(c -> c.getName().equals("k2"))
+                .findFirst()
+                .orElseThrow()
+                .isAllowNull();
+        Assertions.assertFalse(baseK2Nullable,
+                "value column k2 must be NOT NULL in the catalog baseline so 
RESET can prove it stays non-nullable");
+
+        Plan analyzedPlan = PlanChecker.from(connectContext)
+                .analyze("select * from test_stream.s_dup@reset()")
+                .getCascadesContext()
+                .getRewritePlan();
+
+        LogicalOlapTableStreamScan streamScan = 
findFirstLogicalStreamScan(analyzedPlan);
+        Assertions.assertNotNull(streamScan);
+
+        Slot k2 = findSlot(streamScan, "k2");
+        Assertions.assertNotNull(k2);
+        Assertions.assertEquals(baseK2Nullable, k2.nullable(),
+                "RESET scan must keep the base table's original nullability 
for value columns");
+    }
+
+    private static Slot findSlot(LogicalOlapTableStreamScan scan, String name) 
{
+        for (Slot slot : scan.getOutput()) {
+            if (slot.getName().equals(name)) {
+                return slot;
+            }
+        }
+        return null;
+    }
+
+    @Test
+    public void testIncrTimestampRangePropagatesToScanRangeTso() throws 
Exception {
+        // An @incr('startTimestamp'=..., 'endTimestamp'=...) query on the MOW 
base table goes
+        // through makeUniformedTimestampRangeMap in BindRelation, which seeds 
a per-partition
+        // (start, end) offset on the RowBinlogTableWrapper. 
OlapScanNode.getScanRangeLocations then
+        // stamps those offsets onto every scan range as startTso/endTso. 
Verify the whole chain by
+        // asserting every incremental scan range carries the composed 
start/end TSO for its partition.
+        String startTs = "2026-05-25 20:51:28";
+        String endTs = "2026-05-25 21:51:28";
+        long expectedStartTso = 
TSOTimestamp.composeFullTimestamp(OlapScanNode.parseChangeTimestamp(startTs));
+        long expectedEndTso = 
TSOTimestamp.composeFullTimestamp(OlapScanNode.parseChangeTimestamp(endTs));
+
+        ConnectContext ctx = createDefaultCtx();
+        ctx.setDatabase("test_stream");
+        ctx.getSessionVariable().showHiddenColumns = true;
+        StatementScopeIdGenerator.clear();
+        String sql = "explain select * from test_stream.tbl_stream_base"
+                + "@incr('startTimestamp' = '" + startTs + "', 'endTimestamp' 
= '" + endTs + "')";
+        PlanFragment fragment = getFragment(ctx, sql);
+
+        List<OlapScanNode> scanNodes = new ArrayList<>();
+        collectOlapScanNodes(fragment.getPlanRoot(), scanNodes);
+        Assertions.assertFalse(scanNodes.isEmpty());
+
+        boolean assertedAtLeastOne = false;
+        for (OlapScanNode scanNode : scanNodes) {
+            if (!(scanNode.getOlapTable() instanceof RowBinlogTableWrapper)) {
+                continue;
+            }
+            List<TScanRangeLocations> locations = 
scanNode.getScanRangeLocations(Long.MAX_VALUE);
+            Assertions.assertFalse(locations.isEmpty());
+            for (TScanRangeLocations loc : locations) {
+                TPaloScanRange range = loc.getScanRange().getPaloScanRange();
+                Assertions.assertEquals(expectedStartTso, range.getStartTso(),
+                        "startTSO should equal the composed @incr 
startTimestamp for every partition");
+                Assertions.assertEquals(expectedEndTso, range.getEndTso(),
+                        "endTSO should equal the composed @incr endTimestamp 
for every partition");
+                assertedAtLeastOne = true;
+            }
+        }
+        Assertions.assertTrue(assertedAtLeastOne,
+                "expected at least one incremental scan range to assert TSO 
bounds against");
+    }
+
     @Test
     public void testRecordPlanForMvPreRewriteNormalizesStreamScanInsideCte() 
throws Exception {
         ConnectContext ctx = createDefaultCtx();
diff --git 
a/regression-test/data/table_stream_p0/test_min_delta_op_filter_correctness.out 
b/regression-test/data/table_stream_p0/test_min_delta_op_filter_correctness.out
new file mode 100755
index 00000000000..b563cf8c592
--- /dev/null
+++ 
b/regression-test/data/table_stream_p0/test_min_delta_op_filter_correctness.out
@@ -0,0 +1,68 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !stream_append_filtered_1 --
+1      1       a1      APPEND
+2      1       a2      APPEND
+3      1       a3      APPEND
+
+-- !stream_append_filtered_2 --
+1      1       a1      APPEND
+2      1       a2      APPEND
+3      1       a3      APPEND
+
+-- !stream_append_filtered_3 --
+1      1       a1      APPEND
+2      1       a2      APPEND
+3      1       a3      APPEND
+
+-- !audit_round1 --
+APPEND 3
+
+-- !stream_update_filtered_1 --
+1      1       a1      UPDATE_BEFORE
+2      1       a2      UPDATE_BEFORE
+3      1       a3      UPDATE_BEFORE
+
+-- !stream_update_filtered_2 --
+1      2       b1      UPDATE_AFTER
+2      2       b2      UPDATE_AFTER
+3      2       b3      UPDATE_AFTER
+
+-- !stream_update_filtered_3 --
+1      1       a1      UPDATE_BEFORE
+1      2       b1      UPDATE_AFTER
+2      1       a2      UPDATE_BEFORE
+2      2       b2      UPDATE_AFTER
+3      1       a3      UPDATE_BEFORE
+3      2       b3      UPDATE_AFTER
+
+-- !stream_update_filtered_4 --
+1      1       a1      UPDATE_BEFORE
+1      2       b1      UPDATE_AFTER
+2      1       a2      UPDATE_BEFORE
+2      2       b2      UPDATE_AFTER
+3      1       a3      UPDATE_BEFORE
+3      2       b3      UPDATE_AFTER
+
+-- !audit_round2 --
+APPEND 3
+UPDATE_AFTER   3
+UPDATE_BEFORE  3
+
+-- !stream_delete_filtered_1 --
+1      2       b1      DELETE
+2      2       b2      DELETE
+
+-- !stream_delete_filtered_2 --
+1      2       b1      DELETE
+2      2       b2      DELETE
+
+-- !stream_delete_filtered_3 --
+1      2       b1      DELETE
+2      2       b2      DELETE
+
+-- !audit_round3 --
+APPEND 3
+DELETE 2
+UPDATE_AFTER   3
+UPDATE_BEFORE  3
+
diff --git 
a/regression-test/suites/table_stream_p0/test_min_delta_op_filter_correctness.groovy
 
b/regression-test/suites/table_stream_p0/test_min_delta_op_filter_correctness.groovy
new file mode 100644
index 00000000000..455d279e69e
--- /dev/null
+++ 
b/regression-test/suites/table_stream_p0/test_min_delta_op_filter_correctness.groovy
@@ -0,0 +1,196 @@
+// 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_min_delta_op_filter_correctness", "nonConcurrent") {
+    if (isCloudMode()) {
+        return
+    }
+    sql "DROP DATABASE IF EXISTS test_min_delta_op_filter_correctness_db"
+    sql "CREATE DATABASE test_min_delta_op_filter_correctness_db"
+    sql "USE test_min_delta_op_filter_correctness_db"
+    sql "set enable_nereids_planner=true"
+    sql "set enable_fallback_to_original_planner=false"
+
+    try {
+        sql "DROP STREAM IF EXISTS s"
+        sql "DROP TABLE IF EXISTS src"
+        sql "DROP TABLE IF EXISTS audit"
+
+        // MoW UNIQUE KEY with a user-defined sequence column, consumed by a
+        // MIN_DELTA stream. Consumption is materialized via INSERT INTO audit
+        // SELECT ... FROM s, which commits and advances the stream offset.
+        // A bare SELECT over the stream rolls back (does NOT advance) the
+        // offset, so after a DELETE the two identical bare SELECTs must return
+        // the same DELETE rows.
+        sql """
+            CREATE TABLE src (
+                id BIGINT NOT NULL,
+                version_no BIGINT NOT NULL,
+                payload VARCHAR(32) NOT NULL
+            ) ENGINE=OLAP
+            UNIQUE KEY(id)
+            DISTRIBUTED BY HASH(id) BUCKETS 1
+            PROPERTIES (
+                "replication_num" = "1",
+                "enable_unique_key_merge_on_write" = "true",
+                "function_column.sequence_col" = "version_no",
+                "binlog.enable" = "true",
+                "binlog.format" = "ROW",
+                "binlog.need_historical_value" = "true"
+            )
+        """
+
+        sql """
+            CREATE TABLE audit (
+                id BIGINT,
+                version_no BIGINT,
+                payload VARCHAR(32),
+                change_type VARCHAR(32)
+            ) ENGINE=OLAP
+            DUPLICATE KEY(id)
+            DISTRIBUTED BY HASH(id) BUCKETS 1
+            PROPERTIES (
+                "replication_num" = "1"
+            )
+        """
+
+        sql """
+            CREATE STREAM s ON TABLE src
+            PROPERTIES (
+                "type" = "min_delta",
+                "show_initial_rows" = "false"
+            )
+        """
+
+        // Round 1: three fresh inserts -> APPEND, consumed into audit.
+        sql "INSERT INTO src VALUES (1, 1, 'a1'), (2, 1, 'a2'), (3, 1, 'a3')"
+        sql "sync"
+
+        order_qt_stream_append_filtered_1 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('APPEND')
+            ORDER BY id
+        """
+
+        order_qt_stream_append_filtered_2 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            ORDER BY id
+        """
+
+        order_qt_stream_append_filtered_3 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('APPEND', 
'UPDATE_BEFORE', 'UPDATE_AFTER', 'DELETE')
+            ORDER BY id
+        """
+
+        sql """
+            INSERT INTO audit
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('APPEND', 'INSERT', 
'UPDATE_AFTER', 'DELETE')
+        """
+
+        order_qt_audit_round1 """
+            SELECT change_type, count(*) FROM audit GROUP BY change_type ORDER 
BY change_type
+        """
+
+        // Round 2: bump the sequence column for the same keys -> UPDATE, only
+        // the UPDATE_AFTER image survives the WHERE filter and is consumed.
+        sql "INSERT INTO src VALUES (1, 2, 'b1'), (2, 2, 'b2'), (3, 2, 'b3')"
+        sql "sync"
+
+        order_qt_stream_update_filtered_1 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('UPDATE_BEFORE')
+            ORDER BY id
+        """
+
+        order_qt_stream_update_filtered_2 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('UPDATE_AFTER')
+            ORDER BY id
+        """
+
+        order_qt_stream_update_filtered_3 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('APPEND', 
'UPDATE_BEFORE', 'UPDATE_AFTER', 'DELETE')
+            ORDER BY id
+        """
+
+        order_qt_stream_update_filtered_4 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            ORDER BY id
+        """
+
+        sql """
+            INSERT INTO audit
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('APPEND', 
'UPDATE_BEFORE', 'UPDATE_AFTER', 'DELETE')
+        """
+
+        order_qt_audit_round2 """
+            SELECT change_type, count(*) FROM audit GROUP BY change_type ORDER 
BY change_type
+        """
+
+        // Round 3: delete two keys, then read the stream twice with bare
+        // SELECTs. Because bare SELECT rolls back the offset, both reads must
+        // observe the same DELETE rows carrying the pre-delete snapshot.
+        sql "DELETE FROM src WHERE id <= 2"
+        sql "sync"
+
+        order_qt_stream_delete_filtered_1 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            ORDER BY id
+        """
+
+        order_qt_stream_delete_filtered_2 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('DELETE')
+            ORDER BY id
+        """
+
+        order_qt_stream_delete_filtered_3 """
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('APPEND', 
'UPDATE_BEFORE', 'UPDATE_AFTER', 'DELETE')
+            ORDER BY id
+        """
+        sql """
+            INSERT INTO audit
+            SELECT id, version_no, payload, __DORIS_STREAM_CHANGE_TYPE_COL__
+            FROM s
+            WHERE __DORIS_STREAM_CHANGE_TYPE_COL__ IN ('APPEND', 
'UPDATE_BEFORE', 'UPDATE_AFTER', 'DELETE')
+        """
+
+        order_qt_audit_round3 """
+            SELECT change_type, count(*) FROM audit GROUP BY change_type ORDER 
BY change_type
+        """
+
+    } finally {
+        sql "DROP DATABASE IF EXISTS test_min_delta_op_filter_correctness_db"
+    }
+}


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

Reply via email to