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

luwei16 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 efc929aa7af [fix](table stream) fix table stream TSO boundary 
semantics (#67480)
efc929aa7af is described below

commit efc929aa7af6ced10d8a4c7764cfc22fc6bee543
Author: TsukiokaKogane <[email protected]>
AuthorDate: Thu Sep 10 20:11:13 2026 +0800

    [fix](table stream) fix table stream TSO boundary semantics (#67480)
    
    ### What problem does this PR solve?
    
    Issue Number: close #67093
    
    Related PR: #xxx
    
    Problem Summary:
    
    Timestamp-based `@incr` reads are intended to use a left-closed,
    right-open interval: `[startTimestamp, endTimestamp)`.
    
    Previously, FE converted both timestamps to the last logical TSO of
    their respective milliseconds, while BE applied `GT/LE` predicates. This
    produced `(endOfStartMs, endOfEndMs]`.
    
    Consequently, the scan excluded changes in the requested start
    millisecond and included changes in the requested end millisecond. For
    example, a query for `[10:00:00, 10:00:01)` excluded changes at
    `10:00:00.000` and included changes at `10:00:01.000`, assigning
    boundary events to the wrong window.
    
    This PR changes BE TSO predicates to `GE/LT` and updates FE bound
    construction across incremental reads, Table Streams, snapshots, time
    travel, and transaction visibility waiting. Timestamp-based reads now
    follow the intended half-open interval, while Table Stream consumption
    and historical snapshots retain their existing inclusive committed-TSO
    endpoints.
    
    #### Scan bounds and persisted offsets
    
    Table Stream metadata continues to store actual committed TSO values.
    Only scan-facing bounds are converted:
    
    ```text
    (lastConsumed, currentTso]
        ==
    [nextTso(lastConsumed), nextTso(currentTso))
    ```
    
    The conversion happens when constructing the scan view. The underlying
    stream update map and offset commit paths retain the original committed
    TSOs, preventing scan-bound conversion from advancing persisted offsets
    or being applied again on the next consumption.
    
    For the table below:
    
    - `startOf(ms)` means `composeEmptyCounterTSO(ms)`, with logical counter
    zero.
    - `nextTso(t)` means the successor of a valid real TSO.
    - `L`, `C`, and `H` are the last consumed, current committed, and
    historical committed TSOs.
    
    | Read path | Scan-facing bounds | Resulting semantics |
    | --- | --- | --- |
    | `@incr(startTimestamp, endTimestamp)` | `[startOf(startMs),
    startOf(endMs))` | Includes the entire start millisecond and excludes
    the entire end millisecond. |
    | `@incr` without a start timestamp | Starts at `startOf(0)` | Reads
    from the beginning up to the applicable exclusive end. |
    | Incremental scan without an explicit end | Uses `nextTso(C)` when a
    current committed TSO exists | Includes changes at the current committed
    TSO. |
    | Local and Cloud Table Stream consumption | `[nextTso(L), nextTso(C))`
    | Preserves `(L, C]`. |
    | First incremental consumption without a recorded lower bound | Lower
    bound unset; end is `nextTso(C)` | Includes changes through `C`. |
    | DUP snapshot/history scan | Upper bound `nextTso(H)` | Includes rows
    committed at `H`. |
    | MOW snapshot reconstruction | Base rows below `nextTso(H)`; binlog
    changes starting at `nextTso(H)` | Preserves the snapshot at `H`, with
    subsequent changes used to reconstruct before-images. |
    | `FOR VERSION AS OF v` | Exclusive snapshot boundary `nextTso(v)` |
    Keeps the requested version inclusive. |
    | `FOR TIME AS OF ms` | Exclusive snapshot boundary
    `nextTso(startOf(ms))` | Includes every logical TSO within the requested
    millisecond. |
    
    For MOW time travel, the base branch uses `commit_tso < boundary`, and
    the binlog reconstruction branch starts at `commit_tso >= boundary`.
    Both branches therefore use the same snapshot boundary.
    
    #### Missing offsets and empty snapshot partitions
    
    A missing bound or a negative sentinel such as `-1` is not a real
    committed TSO. `toExclusiveBound` converts these values to `null`; valid
    committed TSOs are converted to their successors. When a scan has no
    explicit end, the planner converts the current partition TSO in the same
    way and leaves the end unset if the partition has never received a real
    TSO.
    
    Snapshot partition selection also distinguishes a real consumption
    baseline from an empty-partition marker. A partition that was empty when
    the stream was created can have a recorded offset of `-1`; in Cloud
    mode, it can additionally be marked `CONSUMED`. Neither condition alone
    means that the partition contained data at the snapshot boundary.
    
    This PR requires a positive recorded offset for such partitions to
    participate in the consumed-partition snapshot path. Cloud mode also
    requires the offset field to be present. This prevents snapshot
    reconstruction from falling back to the partition's current TSO and
    exposing rows inserted after the snapshot into a partition that was
    originally empty. Incremental reads can still consume those later
    changes through their normal path.
    
    #### Visibility waiting
    
    The non-cloud visibility waiter now uses `commitTSO < endTSO`, matching
    the exclusive upper-bound representation.
    
    - An explicit positive end timestamp `E` maps to `startOf(E)`, excluding
    transactions in millisecond `E`.
    - When the end is omitted, or the existing non-positive fallback
    applies, query-start millisecond `M` maps to `startOf(M + 1)`. This
    includes all logical counters within `M`, so relevant transactions
    committed in the same millisecond are still awaited.
    
    The Cloud visibility waiter is unchanged and continues to use its
    transaction-ID watermark.
    
    #### TSO range and sentinel handling
    
    `Long.MAX_VALUE` is reserved as `UNBOUNDED_TSO`, representing the
    latest/unbounded version. Real allocated TSOs are limited to
    `MAX_REAL_TSO = Long.MAX_VALUE - 1`.
    
    `nextTso` validates its input and returns `tso + 1`. It does not
    saturate: passing `Long.MAX_VALUE` is rejected. `FOR VERSION AS OF
    Long.MAX_VALUE` handles the sentinel explicitly and keeps it as the
    exclusive upper bound above all real TSOs.
    
    The TSO generator now uses `composeRealTso` to validate physical and
    logical components and reject values outside the real-TSO range,
    enforcing the invariant required by successor conversion.
    
    These changes preserve the stored offset representation. They change the
    interpretation of scan bounds exchanged between FE and BE, so unchanged
    persisted metadata does not imply compatibility between old and new
    FE/BE scan semantics.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test <!-- At least one of them must be included. -->
        - [x] Regression test
        - [x] Unit Test
        - [ ] Manual test (add detailed scripts or steps below)
        - [ ] No need to test or manual test. Explain why:
    - [ ] This is a refactor/code format and no logic has been changed.
            - [ ] Previous test can cover this change.
            - [ ] No code files have been changed.
            - [ ] Other reason <!-- Add your reason?  -->
    
    - Behavior changed:
        - [ ] No.
        - [x] Yes. <!-- Explain the behavior change -->
    
    - Does this need documentation?
        - [x] No.
    - [ ] Yes. <!-- Add document PR link here. eg:
    https://github.com/apache/doris-website/pull/1214 -->
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label <!-- Add branch pick label that this PR
    should merge into -->
---
 be/src/exec/scan/olap_scanner.cpp                  |  5 +-
 .../doris/catalog/stream/OlapTableStream.java      |  8 ++-
 .../catalog/stream/OlapTableStreamWrapper.java     | 28 ++++++--
 .../doris/nereids/rules/analysis/BindRelation.java | 42 ++++++++----
 .../org/apache/doris/planner/OlapScanNode.java     | 24 +++----
 .../main/java/org/apache/doris/tso/TSOService.java |  2 +-
 .../java/org/apache/doris/tso/TSOTimestamp.java    | 74 +++++++++++++++++++---
 .../translator/PhysicalPlanTranslatorTest.java     | 19 ++++--
 .../trees/plans/ExplainTableStreamPlanTest.java    | 72 +++++++++++++++++++--
 .../org/apache/doris/planner/OlapScanNodeTest.java | 26 ++++++++
 .../org/apache/doris/tso/TSOTimestampTest.java     | 15 +++++
 11 files changed, 261 insertions(+), 54 deletions(-)

diff --git a/be/src/exec/scan/olap_scanner.cpp 
b/be/src/exec/scan/olap_scanner.cpp
index 4fafd8ffb30..d0d3a03bee9 100644
--- a/be/src/exec/scan/olap_scanner.cpp
+++ b/be/src/exec/scan/olap_scanner.cpp
@@ -332,13 +332,14 @@ Status OlapScanner::_init_tso_predicates() {
 
     const auto* tso_column = read_schema->column(tso_ordinal);
     const auto& tso_data_type = read_schema->data_type(tso_ordinal);
+    // The TSO scan range is left-closed right-open [start_tso, end_tso).
     if (_start_tso.has_value()) {
-        
_tablet_reader_params.predicates.push_back(create_comparison_predicate<PredicateType::GT>(
+        
_tablet_reader_params.predicates.push_back(create_comparison_predicate<PredicateType::GE>(
                 tso_ordinal, tso_column->name(), tso_data_type,
                 Field::create_field<TYPE_BIGINT>(*_start_tso), false));
     }
     if (_end_tso.has_value()) {
-        
_tablet_reader_params.predicates.push_back(create_comparison_predicate<PredicateType::LE>(
+        
_tablet_reader_params.predicates.push_back(create_comparison_predicate<PredicateType::LT>(
                 tso_ordinal, tso_column->name(), tso_data_type,
                 Field::create_field<TYPE_BIGINT>(*_end_tso), false));
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStream.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStream.java
index 20f6bf915e8..ec6fde1a97f 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStream.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStream.java
@@ -230,7 +230,13 @@ public class OlapTableStream extends BaseTableStream {
     }
 
     public boolean hasConsumedData(long partitionId) {
-        return partitionOffset.containsKey(partitionId);
+        // A partition that was empty at stream creation is recorded with the 
sentinel offset -1
+        // (see initializeLocalOffsets); a real committed TSO is always 
positive (its physical part
+        // is non-zero). So only a positive recorded offset counts as a real 
consumption baseline.
+        // This keeps empty partitions out of the snapshot scan instead of 
letting them fall back to
+        // the live partition TSO and leak post-snapshot rows.
+        Long offset = partitionOffset.get(partitionId);
+        return offset != null && offset > 0;
     }
 
     public Pair<Long, Long> getStreamUpdate(Long partitionId) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStreamWrapper.java
 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStreamWrapper.java
index 3fc9ca11a6a..d6ed6f2411a 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStreamWrapper.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/stream/OlapTableStreamWrapper.java
@@ -27,6 +27,7 @@ import org.apache.doris.common.Pair;
 import org.apache.doris.common.util.Util;
 import org.apache.doris.thrift.TColumn;
 import org.apache.doris.thrift.TPrimitiveType;
+import org.apache.doris.tso.TSOTimestamp;
 
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableList;
@@ -306,8 +307,17 @@ public class OlapTableStreamWrapper extends OlapTable {
     public List<Long> filterConsumedPartitionIds(List<Long> partitionIds) {
         if (hasCloudReadStates()) {
             return partitionIds.stream()
-                    .filter(id -> cloudReadStates.get(id).getOffsetState()
-                            == 
Cloud.TableStreamOffsetStatePB.TABLE_STREAM_OFFSET_CONSUMED)
+                    .filter(id -> {
+                        Cloud.TableStreamPartitionReadStatePB state = 
cloudReadStates.get(id);
+                        // A partition empty at stream creation is recorded as 
CONSUMED with the
+                        // sentinel offset -1 (CloudInternalCatalog: 
emptyPartition -> commit_tso=-1).
+                        // Such a partition has no real consumption baseline; 
exclude it so snapshot
+                        // rebuild does not fall back to the live TSO and leak 
post-snapshot rows.
+                        // Mirrors the non-cloud hasConsumedData() offset > 0 
guard.
+                        return state.getOffsetState()
+                                == 
Cloud.TableStreamOffsetStatePB.TABLE_STREAM_OFFSET_CONSUMED
+                                && state.hasOffsetTso() && 
state.getOffsetTso() > 0;
+                    })
                     .collect(ImmutableList.toImmutableList());
         }
         return partitionIds.stream()
@@ -333,14 +343,24 @@ public class OlapTableStreamWrapper extends OlapTable {
     public Map<Long, Pair<Long, Long>> getPartitionOffsets(List<Long> 
selectedPartitionIds) {
         return outputUpdateMap.entrySet().stream()
                 .filter(s -> selectedPartitionIds.contains(s.getKey()))
-                .collect(Collectors.toMap(Map.Entry::getKey, 
Map.Entry::getValue));
+                .collect(Collectors.toMap(Map.Entry::getKey, s -> {
+                    // Storage keeps the real committed TSO points 
(closed-interval semantics).
+                    // BE scans a left-closed right-open range [startTso, 
endTso), so convert the
+                    // bounds only in this scan-facing read view. 
outputUpdateMap and the offset
+                    // commit path (toOlapTableStreamUpdate) stay on the 
real-TSO coordinate system.
+                    Pair<Long, Long> v = s.getValue();
+                    return Pair.of(TSOTimestamp.toExclusiveBound(v.first),
+                            TSOTimestamp.toExclusiveBound(v.second));
+                }));
     }
 
     // get history partition offsets partitionId -> (null, 
historicalTimestampOffset)
     public Map<Long, Pair<Long, Long>> getHistoryPartitionOffsets(List<Long> 
selectedPartitionIds) {
         return outputUpdateMap.entrySet().stream()
                 .filter(s -> selectedPartitionIds.contains(s.getKey()))
-                .collect(Collectors.toMap(Map.Entry::getKey, s -> 
Pair.of(null, s.getValue().first)));
+                // historicalTso is an inclusive upper bound; shift to the 
half-open exclusive end.
+                .collect(Collectors.toMap(Map.Entry::getKey,
+                        s -> Pair.of(null, 
TSOTimestamp.toExclusiveBound(s.getValue().first))));
     }
 
     public List<Long> filterNormalSnapshotPartitionIds(List<Long> 
partitionIds) {
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 7cafa1bc918..60479e23a8b 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
@@ -74,7 +74,7 @@ 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.InPredicate;
-import org.apache.doris.nereids.trees.expressions.LessThanEqual;
+import org.apache.doris.nereids.trees.expressions.LessThan;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
 import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.expressions.SlotReference;
@@ -502,8 +502,8 @@ public class BindRelation extends OneAnalysisRuleFactory {
 
     /**
      * Build the time-travel (FOR VERSION/TIME AS OF) plan for an olap scan.
-     * dup: Filter(__DORIS_COMMIT_TSO_COL__ &lt;= targetTso).
-     * mow: base(survived rows, tso&lt;=t1) UNION ALL binlog(before-image of 
UPDATE_BEFORE/DELETE).
+     * dup: Filter(__DORIS_COMMIT_TSO_COL__ &lt; targetTso), targetTso being 
the exclusive upper bound.
+     * mow: base(survived rows, tso &lt; targetTso) UNION ALL 
binlog(before-image of UPDATE_BEFORE/DELETE).
      */
     private LogicalPlan buildTimeTravelPlan(LogicalOlapScan scan, OlapTable 
olapTable,
             TableSnapshot snapshot, UnboundRelation unboundRelation, 
List<String> qualifier,
@@ -541,8 +541,9 @@ public class BindRelation extends OneAnalysisRuleFactory {
     }
 
     /**
-     * Add Filter(__DORIS_COMMIT_TSO_COL__ &lt;= targetTso) on top of {@code 
child}. The tso slot is
-     * resolved from {@code child} output; {@code child} must pass through the 
scan output slots.
+     * Add Filter(__DORIS_COMMIT_TSO_COL__ &lt; targetTso) on top of {@code 
child}, where targetTso is
+     * the right-open (exclusive) upper bound from resolveSnapshotTso. The tso 
slot is resolved from
+     * {@code child} output; {@code child} must pass through the scan output 
slots.
      */
     private LogicalPlan addCommitTsoFilter(LogicalPlan child, long targetTso, 
OlapTable olapTable) {
         Slot tsoSlot = null;
@@ -554,25 +555,35 @@ public class BindRelation extends OneAnalysisRuleFactory {
         }
         Preconditions.checkArgument(tsoSlot != null,
                 "%s not found on table %s", Column.COMMIT_TSO_COL, 
olapTable.getQualifiedName());
-        Expression conjunct = new LessThanEqual(tsoSlot, new 
BigIntLiteral(targetTso));
+        Expression conjunct = new LessThan(tsoSlot, new 
BigIntLiteral(targetTso));
         return new LogicalFilter<>(ImmutableSet.of(conjunct), child);
     }
 
     /**
-     * Resolve a TableSnapshot to a target commit tso (inclusive upper bound).
-     * VERSION: the literal is the tso itself. TIME: wall-clock string -&gt; 
ms -&gt; tso upper bound.
+     * Resolve a TableSnapshot to the right-open (exclusive) commit-tso upper 
bound: the scan keeps
+     * rows with commit_tso &lt; the returned value. VERSION: literal tso + 1 
(so the literal itself is
+     * included). TIME: successor of (requested millisecond, logical counter 
0), so that exact TSO
+     * is included but larger logical counters in the same millisecond are 
excluded. Used uniformly
+     * by the dup filter, the mow union left filter and the mow union 
right-branch lower bound.
      */
     private long resolveSnapshotTso(TableSnapshot snapshot) {
         if (snapshot.getType() == TableSnapshot.VersionType.VERSION) {
+            long version;
             try {
-                return Long.parseLong(snapshot.getValue().trim());
+                version = Long.parseLong(snapshot.getValue().trim());
             } catch (NumberFormatException e) {
                 throw new AnalysisException(
                         "Invalid version in FOR VERSION AS OF: " + 
snapshot.getValue());
             }
+            // UNBOUNDED_TSO (Long.MAX_VALUE) is the "latest / unbounded" 
sentinel: keep it as the
+            // exclusive upper bound above every real TSO. A real version is 
converted to its
+            // right-open successor so that commit_tso < result includes the 
requested version.
+            return version == TSOTimestamp.UNBOUNDED_TSO
+                    ? TSOTimestamp.UNBOUNDED_TSO
+                    : TSOTimestamp.nextTso(version);
         }
         long ms = OlapScanNode.parseChangeTimestamp(snapshot.getValue());
-        return TSOTimestamp.composeFullTimestamp(ms);
+        return TSOTimestamp.nextTso(TSOTimestamp.composePhysicalTimestamp(ms));
     }
 
     /**
@@ -589,14 +600,16 @@ public class BindRelation extends OneAnalysisRuleFactory {
                         || ((SlotReference) slot).isVisible())
                 .collect(Collectors.toList());
 
-        // left: base survived rows at t1 = delete_sign=0 AND commit_tso<=t1, 
projected to visible.
+        // left: base survived rows at t1 = delete_sign=0 AND commit_tso < 
targetTso, projected to visible.
         LogicalPlan left = checkAndAddDeleteSignFilter(baseScan, 
ConnectContext.get(), olapTable, true);
         left = projectFromOriginSlots(addCommitTsoFilter(left, targetTso, 
olapTable), visibleOutput);
 
-        // right: binlog MIN_DELTA over tso>t1, keep UPDATE_BEFORE/DELETE rows 
(before image),
+        // right: binlog MIN_DELTA over tso >= targetTso, 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, CollectionUtils.isEmpty(partIds)
+                // targetTso is the exclusive upper bound; the right branch 
reads tso >= targetTso,
+                // seamlessly meeting the left branch's commit_tso < targetTso.
                 ? makeUniformedTimestampRangeMap(olapTable.getPartitionIds(), 
Pair.of(targetTso, null)) :
                 makeUniformedTimestampRangeMap(partIds, Pair.of(targetTso, 
null)));
         RelationId binlogRelationId = 
cascadesContext.getStatementContext().getNextRelationId();
@@ -709,10 +722,11 @@ public class BindRelation extends OneAnalysisRuleFactory {
 
     private Pair<Long, Long> parseTimestampRange(TableScanParams scanParams) {
         Map<String, String> params = scanParams.getMapParams();
+        // @incr reads a left-closed right-open range [startTso, endTso): BE 
applies GE/LT directly.
+        // composePhysicalTimestamp maps a millisecond to its start (logical 
counter 0), so GE includes
+        // the whole startMs and LT excludes the whole endMs. No +1 shift is 
needed here.
         Long startTimestamp = OlapScanNode.parseChangeTimestamp(
                 params.getOrDefault(OlapScanNode.OLAP_START_TIMESTAMP, "0"));
-        // BE applies start_tso < commit_tso <= end_tso. Logical zero on both 
boundaries
-        // therefore gives the user-facing physical interval [startTimestamp, 
endTimestamp).
         startTimestamp = TSOTimestamp.composePhysicalTimestamp(startTimestamp);
         Long endTimestamp = null;
         if (params.containsKey((OlapScanNode.OLAP_END_TIMESTAMP))) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/planner/OlapScanNode.java 
b/fe/fe-core/src/main/java/org/apache/doris/planner/OlapScanNode.java
index 3418137d57c..fcbd9151002 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/OlapScanNode.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/OlapScanNode.java
@@ -100,6 +100,7 @@ import org.apache.doris.thrift.TScanRange;
 import org.apache.doris.thrift.TScanRangeLocation;
 import org.apache.doris.thrift.TScanRangeLocations;
 import org.apache.doris.thrift.TSortInfo;
+import org.apache.doris.tso.TSOTimestamp;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
@@ -581,13 +582,17 @@ public class OlapScanNode extends ScanNode {
                         parseBinlogScanType(scanParams, ((OlapTableWrapper) 
olapTable).getOriginTable());
                 Pair<Long, Long> update = 
getPartitionOffset(partition.getId());
                 if (update != null) {
+                    // push down tso range as half-open [startTso, endTso) 
bounds
                     if (update.first != null) {
                         paloRange.setStartTso(update.first);
                     }
-                    if (update.second != null) {
-                        paloRange.setEndTso(update.second);
-                    } else {
-                        paloRange.setEndTso(partition.getTso());
+                    // No end recorded: fall back to the current committed 
TSO. toExclusiveBound
+                    // returns null for a partition that never got a real TSO 
(getTso() == -1), in
+                    // which case we leave endTso unset (no upper bound) 
instead of using -1.
+                    Long endTso = update.second != null
+                            ? update.second : 
TSOTimestamp.toExclusiveBound(partition.getTso());
+                    if (endTso != null) {
+                        paloRange.setEndTso(endTso);
                     }
                 }
                 if (binlogScanType != TBinlogScanType.NONE) {
@@ -1964,14 +1969,6 @@ public class OlapScanNode extends ScanNode {
         return scanParams;
     }
 
-    public long getIncrementalScanEndTime() {
-        if (scanParams != null && scanParams.incrementalRead()
-                && scanParams.getMapParams().containsKey(OLAP_END_TIMESTAMP)) {
-            return 
parseChangeTimestamp(scanParams.getMapParams().get(OLAP_END_TIMESTAMP));
-        }
-        return 0;
-    }
-
     public static long parseChangeTimestamp(String ts) {
         if (ts != null) {
             long changeTimestamp;
@@ -1983,6 +1980,9 @@ public class OlapScanNode extends ScanNode {
             if (changeTimestamp < 0) {
                 throw new ParseException("Invalid TIMESTAMP format in incr 
clause: " + ts);
             }
+            if (changeTimestamp > TSOTimestamp.MAX_PHYSICAL_TIMESTAMP) {
+                throw new ParseException("Timestamp exceeds supported TSO 
range: " + ts);
+            }
             return changeTimestamp;
         }
         throw new ParseException("Invalid timestamp:" + ts);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/tso/TSOService.java 
b/fe/fe-core/src/main/java/org/apache/doris/tso/TSOService.java
index 2acfff15564..bb4d3e0e949 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/tso/TSOService.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/tso/TSOService.java
@@ -243,7 +243,7 @@ public class TSOService extends MasterDaemon {
             if (MetricRepo.isInit) {
                 MetricRepo.COUNTER_TSO_CLOCK_GET_SUCCESS.increase(1L);
             }
-            return TSOTimestamp.composeTimestamp(physical, logical);
+            return TSOTimestamp.composeRealTso(physical, logical);
         }
         throw new RuntimeException("Failed to get TSO after " + 
maxGetTSORetryCount + " retries", lastFailure);
     }
diff --git a/fe/fe-core/src/main/java/org/apache/doris/tso/TSOTimestamp.java 
b/fe/fe-core/src/main/java/org/apache/doris/tso/TSOTimestamp.java
index 191bd6f71ea..0afc9eeb11a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/tso/TSOTimestamp.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/tso/TSOTimestamp.java
@@ -22,6 +22,7 @@ import org.apache.doris.common.io.Writable;
 import org.apache.doris.persist.gson.GsonUtils;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
 import com.google.gson.annotations.SerializedName;
 
 import java.io.DataInput;
@@ -67,6 +68,18 @@ public final class TSOTimestamp implements Writable, 
Comparable<TSOTimestamp> {
     // Maximum logical counter value
     public static final long MAX_LOGICAL_COUNTER = (1L << LOGICAL_BITS) - 1L;
 
+    // Sentinel meaning "no upper bound" / "latest" (e.g. FOR VERSION AS OF 
9223372036854775807).
+    // It is intentionally NOT a real allocated TSO; it sorts above every real 
TSO so that a
+    // right-open predicate {@code x < UNBOUNDED_TSO} still selects all rows.
+    public static final long UNBOUNDED_TSO = Long.MAX_VALUE;
+
+    // The largest legal real (allocated) TSO. Real TSOs never reach 
UNBOUNDED_TSO, which leaves
+    // room for nextTso() to compute a successor without overflow.
+    public static final long MAX_REAL_TSO = Long.MAX_VALUE - 1;
+
+    // Largest physical millisecond that can be represented by a real TSO.
+    public static final long MAX_PHYSICAL_TIMESTAMP = 
extractPhysicalTime(MAX_REAL_TSO);
+
     /**
      * Constructor with specific physical time and logical counter
      *
@@ -104,21 +117,44 @@ public final class TSOTimestamp implements Writable, 
Comparable<TSOTimestamp> {
     }
 
     /**
-     * Compose 64-bit TSO timestamp from physical time
+     * Compose the lower boundary of a physical millisecond.
      *
-     * @return 64-bit TSO timestamp with full counter
+     * @return 64-bit TSO timestamp with a zero logical counter
      */
-    public static long composeFullTimestamp(long physicalTimestamp) {
-        return composeTimestamp(physicalTimestamp, LOGICAL_MASK);
+    public static long composePhysicalTimestamp(long physicalTimestamp) {
+        return composeRealTso(physicalTimestamp, 0L);
     }
 
     /**
-     * Compose the lower boundary of a physical millisecond.
+     * The next discrete TSO after a real {@code tso}. TSO values are dense 
integers, so this converts
+     * an inclusive bound into the equivalent right-open (exclusive) bound: 
{@code x <= tso} is the
+     * same row set as {@code x < nextTso(tso)}, and a lower bound that 
excludes {@code tso} itself is
+     * {@code x >= nextTso(tso)}. Callers should use this instead of a bare 
{@code + 1} so the TSO
+     * interval arithmetic stays in one place.
      *
-     * @return 64-bit TSO timestamp with a zero logical counter
+     * <p>This is a pure successor over real TSOs only. The {@link 
#UNBOUNDED_TSO} sentinel is not a
+     * real TSO and must be handled by callers before reaching here; requiring 
a real input keeps the
+     * successor free of overflow and makes the "real TSOs never reach the 
sentinel" assumption an
+     * enforced invariant rather than a comment.
      */
-    public static long composePhysicalTimestamp(long physicalTimestamp) {
-        return composeTimestamp(physicalTimestamp, 0L);
+    public static long nextTso(long tso) {
+        Preconditions.checkArgument(tso >= 0 && tso <= MAX_REAL_TSO,
+                "nextTso expects a real TSO in [0, %s], got %s", MAX_REAL_TSO, 
tso);
+        return tso + 1;
+    }
+
+    /**
+     * Convert an inclusive stored TSO bound into the half-open (exclusive) 
bound the scan pushes
+     * down, tolerating "no bound" inputs. Returns {@code null} when the input 
is {@code null} or a
+     * negative sentinel (e.g. a partition that never got a real TSO stores 
-1, meaning no committed
+     * change): a {@code null} result tells the caller to leave that bound 
unset rather than feeding
+     * a non-real value into {@link #nextTso}. A real TSO is mapped to its 
successor.
+     */
+    public static Long toExclusiveBound(Long storedTso) {
+        if (storedTso == null || storedTso < 0) {
+            return null;
+        }
+        return nextTso(storedTso);
     }
 
     /**
@@ -215,6 +251,28 @@ public final class TSOTimestamp implements Writable, 
Comparable<TSOTimestamp> {
             | (logical);
     }
 
+    /**
+     * Compose a real (allocated) TSO from physical time and logical counter, 
validating that the
+     * inputs and the result stay within the legal real-TSO range instead of 
silently masking. This
+     * is the single construction entry for TSOs produced by the generator, so 
the invariant
+     * "a real TSO is non-negative and never reaches {@link #UNBOUNDED_TSO}" 
is enforced here once
+     * rather than assumed at every call site.
+     *
+     * @throws IllegalArgumentException if the components are out of range or 
the composed value
+     *         would exceed {@link #MAX_REAL_TSO}
+     */
+    public static long composeRealTso(long physicalTime, long logicalCounter) {
+        Preconditions.checkArgument(physicalTime >= 0 && physicalTime <= 
RAW_PHYSICAL_MASK,
+                "physicalTime out of range [0, %s]: %s", RAW_PHYSICAL_MASK, 
physicalTime);
+        Preconditions.checkArgument(logicalCounter >= 0 && logicalCounter <= 
MAX_LOGICAL_COUNTER,
+                "logicalCounter out of range [0, %s]: %s", 
MAX_LOGICAL_COUNTER, logicalCounter);
+        long tso = Math.addExact(
+                Math.multiplyExact(physicalTime, 1L << PHYSICAL_SHIFT), 
logicalCounter);
+        Preconditions.checkArgument(tso <= MAX_REAL_TSO,
+                "composed TSO exceeds MAX_REAL_TSO (%s): %s", MAX_REAL_TSO, 
tso);
+        return tso;
+    }
+
     public static long extractTimestamp(long tso) {
         //  extract physical time from TSO timestamp by remove Lower 18 bits 
logical counter bits
         return (tso >> PHYSICAL_SHIFT);
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorTest.java
index be631b212d3..cf2f92487bd 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/glue/translator/PhysicalPlanTranslatorTest.java
@@ -144,11 +144,20 @@ public class PhysicalPlanTranslatorTest extends 
TestWithFeService {
                 + "'enable_unique_key_merge_on_write' = 'false',"
                 + "'sequence_mapping.s1' = 'v1',"
                 + "'sequence_mapping.s2' = 'v2');");
+        // Bump the base table with a real (positive) tso before creating the 
stream so that the
+        // stream records a positive consumption offset at creation time; 
hasConsumedData() only
+        // treats a positive offset as a real baseline, otherwise every 
partition would be pruned out
+        // of the snapshot scan.
+        Database database = (Database) 
Env.getCurrentInternalCatalog().getDbOrMetaException("test_db");
+        OlapTable binlogScanSchemaTable =
+                (OlapTable) 
database.getTableOrMetaException("binlog_scan_schema_t");
+        bumpPartitionsAndReplicas(binlogScanSchemaTable, 2L, 100L);
         createTable("create stream test_db.binlog_scan_schema_stream "
                 + "on table test_db.binlog_scan_schema_t properties('type' = 
'append_only')");
-        Database database = (Database) 
Env.getCurrentInternalCatalog().getDbOrMetaException("test_db");
-        bumpPartitionsAndReplicas(
-                (OlapTable) 
database.getTableOrMetaException("binlog_scan_schema_t"), 2L);
+        // Advance the base table tso again after the stream is created so the 
partition has new data
+        // beyond the recorded consumption offset, driving the snapshot scan 
down the rebuild path
+        // (base scan wrapped in OlapTableWrapper unioned with the binlog 
before-image).
+        bumpPartitionsAndReplicas(binlogScanSchemaTable, 3L, 200L);
         createTable("create table test_db.t_topn_lazy(c1 int, c2 int, c3 int) "
                 + "duplicate key(c1) distributed by hash(c1) buckets 1 "
                 + "properties('replication_num' = '1', 'light_schema_change' = 
'true');");
@@ -544,10 +553,10 @@ public class PhysicalPlanTranslatorTest extends 
TestWithFeService {
         return scanNodes;
     }
 
-    private static void bumpPartitionsAndReplicas(OlapTable table, long 
newVersion) {
+    private static void bumpPartitionsAndReplicas(OlapTable table, long 
newVersion, long tso) {
         for (Partition partition : table.getPartitions()) {
             long timestamp = System.currentTimeMillis();
-            partition.setVisibleVersionAndTime(newVersion, timestamp, 
timestamp);
+            partition.setVisibleVersionAndTime(newVersion, timestamp, tso);
             partition.setNextVersion(newVersion + 1);
             for (MaterializedIndex index : 
partition.getMaterializedIndices(IndexExtState.VISIBLE, true)) {
                 for (Tablet tablet : index.getTablets()) {
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 fc4f3f77f0f..e139ab4ab5e 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
@@ -25,7 +25,6 @@ import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.MaterializedIndex;
 import org.apache.doris.catalog.MaterializedIndex.IndexExtState;
 import org.apache.doris.catalog.OlapTable;
-import org.apache.doris.catalog.OlapTableWrapper;
 import org.apache.doris.catalog.Partition;
 import org.apache.doris.catalog.Replica;
 import org.apache.doris.catalog.RowBinlogTableWrapper;
@@ -36,6 +35,7 @@ import org.apache.doris.catalog.stream.OlapTableStreamUpdate;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.FeConstants;
 import org.apache.doris.common.Pair;
+import org.apache.doris.common.util.TimeUtils;
 import org.apache.doris.nereids.NereidsPlanner;
 import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.glue.translator.PhysicalPlanTranslator;
@@ -43,12 +43,17 @@ import 
org.apache.doris.nereids.glue.translator.PlanTranslatorContext;
 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.Expression;
+import org.apache.doris.nereids.trees.expressions.LessThan;
 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.BigIntLiteral;
 import org.apache.doris.nereids.trees.expressions.literal.VarcharLiteral;
 import org.apache.doris.nereids.trees.plans.commands.ExplainCommand;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalOlapTableStreamScan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
@@ -71,6 +76,7 @@ import org.junit.jupiter.api.Test;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * UTs for table stream query plan, including
@@ -359,11 +365,12 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
                 TPaloScanRange range = loc.getScanRange().getPaloScanRange();
                 long tabletId = range.getTabletId();
                 long pid = tabletIdToPartitionId.get(tabletId);
-                long expectedStart = stream.getStreamUpdate(pid).first;
+                // BE reads [startTso, endTso), so the recorded offset is 
shifted to its next TSO.
+                long expectedStart = 
TSOTimestamp.nextTso(stream.getStreamUpdate(pid).first);
                 Assertions.assertEquals(expectedScanType, 
range.getBinlogScanType(),
                         "binlog scan type should match stream consume type");
                 Assertions.assertEquals(expectedStart, range.getStartTso(),
-                        "startTSO should equal stream partitionOffset (last 
committed binlog TSO)");
+                        "startTSO should equal stream partitionOffset (last 
committed binlog TSO) + 1");
                 assertedAtLeastOne = true;
             }
         }
@@ -396,11 +403,14 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
             }
         }
         Assertions.assertNotNull(incrementalScan1);
-        OlapTableWrapper wrapper = (OlapTableWrapper) 
incrementalScan1.getOlapTable();
         Map<Long, Long> prevOffsets = new java.util.HashMap<>();
         Map<Long, Long> nextOffsets = new java.util.HashMap<>();
         for (Long pid : incrementalScan1.getSelectedPartitionIds()) {
-            Pair<Long, Long> off = wrapper.getPartitionOffset(pid);
+            // Use the raw (un-shifted) stream offsets, mirroring what the 
production
+            // StreamConsumptionInfoExtractor commits. Reading them back from 
the scan node's
+            // RowBinlogTableWrapper would return the already +1-shifted 
scan-range bounds and
+            // introduce a spurious double shift into this closed-loop check.
+            Pair<Long, Long> off = stream.getStreamUpdate(pid);
             if (off.first != null) {
                 prevOffsets.put(pid, off.first);
             }
@@ -440,8 +450,9 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
             for (TScanRangeLocations loc : locations) {
                 TPaloScanRange range = loc.getScanRange().getPaloScanRange();
                 long pid = tabletIdToPartitionId.get(range.getTabletId());
-                Assertions.assertEquals(nextOffsets.get(pid), 
range.getStartTso(),
-                        "after offset commit, new startTSO must equal the 
previously committed next TSO");
+                // BE reads [startTso, endTso), so the stream wrapper shifts 
the recorded offset by +1.
+                
Assertions.assertEquals(TSOTimestamp.nextTso(nextOffsets.get(pid)), 
range.getStartTso(),
+                        "after offset commit, new startTSO must equal the 
previously committed next TSO + 1");
                 assertedAtLeastOne = true;
             }
         }
@@ -586,6 +597,7 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
         // 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";
+        // @incr is left-closed right-open [start, end): BE uses GE/LT on the 
composed bounds directly.
         long expectedStartTso = 
TSOTimestamp.composePhysicalTimestamp(OlapScanNode.parseChangeTimestamp(startTs));
         long expectedEndTso = 
TSOTimestamp.composePhysicalTimestamp(OlapScanNode.parseChangeTimestamp(endTs));
 
@@ -652,6 +664,52 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
         }
     }
 
+    @Test
+    public void testDupTimeTravelIncludesExactTimestamp() {
+        assertTimeTravelBoundary("tbl_dup_stream_base", "time as of '0'", 1L, 
false);
+        String timestamp = TimeUtils.longToTimeString(1700000000000L);
+        // (1700000000000 ms, logical 0) is inclusive; logical 1 is the 
exclusive upper bound.
+        assertTimeTravelBoundary("tbl_dup_stream_base", "time as of '" + 
timestamp + "'",
+                445644800000000001L, false);
+    }
+
+    @Test
+    public void testMowTimeTravelIncludesExactTimestamp() {
+        assertTimeTravelBoundary("tbl_stream_base", "time as of '0'", 1L, 
true);
+        String timestamp = TimeUtils.longToTimeString(1700000000000L);
+        assertTimeTravelBoundary("tbl_stream_base", "time as of '" + timestamp 
+ "'",
+                445644800000000001L, true);
+    }
+
+    private void assertTimeTravelBoundary(String table, String snapshot, long 
exclusiveBound, boolean mow) {
+        Plan plan = PlanChecker.from(connectContext)
+                .analyze("select * from test_stream." + table + " for " + 
snapshot)
+                .getCascadesContext().getRewritePlan();
+        Set<LogicalFilter<?>> filters = plan.collect(node -> node instanceof 
LogicalFilter);
+        List<Expression> commitPredicates = new ArrayList<>();
+        for (LogicalFilter<?> filter : filters) {
+            for (Expression conjunct : filter.getConjuncts()) {
+                if (conjunct instanceof LessThan && conjunct.child(0) 
instanceof SlotReference
+                        && ((SlotReference) 
conjunct.child(0)).getName().equals(Column.COMMIT_TSO_COL)) {
+                    commitPredicates.add(conjunct);
+                }
+            }
+        }
+        Assertions.assertEquals(1, commitPredicates.size());
+        Assertions.assertEquals(new BigIntLiteral(exclusiveBound), 
commitPredicates.get(0).child(1));
+
+        Set<LogicalOlapScan> binlogScans = plan.collect(node -> node 
instanceof LogicalOlapScan
+                && ((LogicalOlapScan) node).getTable() instanceof 
RowBinlogTableWrapper);
+        Assertions.assertEquals(mow ? 1 : 0, binlogScans.size());
+        for (LogicalOlapScan scan : binlogScans) {
+            RowBinlogTableWrapper wrapper = (RowBinlogTableWrapper) 
scan.getTable();
+            Assertions.assertFalse(wrapper.getPartitionIds().isEmpty());
+            for (Long partitionId : wrapper.getPartitionIds()) {
+                Assertions.assertEquals(Pair.of(exclusiveBound, null), 
wrapper.getPartitionOffset(partitionId));
+            }
+        }
+    }
+
     @Test
     public void testMowTimeTravelQualifiedColumnCanBind() {
         // MOW time-travel goes through a union whose outputs are rebuilt with 
empty qualifiers.
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/planner/OlapScanNodeTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/planner/OlapScanNodeTest.java
index 93a8ed6cbe3..0c3b524cd12 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/planner/OlapScanNodeTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/planner/OlapScanNodeTest.java
@@ -46,7 +46,9 @@ import org.apache.doris.cloud.catalog.CloudPartition;
 import org.apache.doris.common.AnalysisException;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.util.DebugPointUtil;
+import org.apache.doris.common.util.TimeUtils;
 import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.nereids.exceptions.ParseException;
 import org.apache.doris.system.Backend;
 import org.apache.doris.thrift.TOlapScanNode;
 import org.apache.doris.thrift.TPaloScanRange;
@@ -64,6 +66,8 @@ import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -73,6 +77,28 @@ import java.util.Set;
 import java.util.stream.Collectors;
 
 public class OlapScanNodeTest {
+    @Test
+    public void testParseChangeTimestampRange() {
+        DateTimeFormatter format = TimeUtils.getDatetimeFormatWithTimeZone();
+        Assertions.assertEquals(0L, OlapScanNode.parseChangeTimestamp("0"));
+        Assertions.assertEquals(1700000000000L,
+                
OlapScanNode.parseChangeTimestamp(format.format(Instant.ofEpochMilli(1700000000000L))));
+        // The parser accepts whole seconds; these straddle the physical limit 
35184372088831 ms.
+        Assertions.assertEquals(35184372088000L,
+                
OlapScanNode.parseChangeTimestamp(format.format(Instant.ofEpochMilli(35184372088000L))));
+        ParseException error = Assertions.assertThrows(ParseException.class,
+                () -> 
OlapScanNode.parseChangeTimestamp(format.format(Instant.ofEpochMilli(35184372089000L))));
+        Assertions.assertTrue(error.getMessage().contains("Timestamp exceeds 
supported TSO range"));
+        Assertions.assertThrows(ParseException.class,
+                () -> OlapScanNode.parseChangeTimestamp("4000-01-01 
00:00:00"));
+        Assertions.assertThrows(ParseException.class,
+                () -> OlapScanNode.parseChangeTimestamp("9999-01-01 
00:00:00"));
+        Assertions.assertThrows(ParseException.class,
+                () -> 
OlapScanNode.parseChangeTimestamp(format.format(Instant.ofEpochMilli(-1000L))));
+        Assertions.assertThrows(ParseException.class, () -> 
OlapScanNode.parseChangeTimestamp("invalid"));
+        Assertions.assertThrows(ParseException.class, () -> 
OlapScanNode.parseChangeTimestamp(null));
+    }
+
     private MaterializedIndex createMaterializedIndex(List<Long> tabletIds) {
         MaterializedIndex index = new MaterializedIndex();
         List<Tablet> tablets = 
Lists.newArrayListWithExpectedSize(tabletIds.size());
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/tso/TSOTimestampTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/tso/TSOTimestampTest.java
index 7f6ebb52e3a..c65dae049d6 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/tso/TSOTimestampTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/tso/TSOTimestampTest.java
@@ -68,6 +68,21 @@ public class TSOTimestampTest {
         org.junit.jupiter.api.Assertions.assertEquals(0L, 
TSOTimestamp.extractLogicalCounter(composed));
     }
 
+    @Test
+    public void testComposePhysicalTimestampRange() {
+        // Signed TSOs reserve 18 low bits for the logical counter.
+        long maxPhysicalTime = (1L << 45) - 1;
+        Assertions.assertEquals(0L, TSOTimestamp.composePhysicalTimestamp(0L));
+        Assertions.assertEquals(Long.MAX_VALUE - 
TSOTimestamp.MAX_LOGICAL_COUNTER,
+                TSOTimestamp.composePhysicalTimestamp(maxPhysicalTime));
+        Assertions.assertThrows(IllegalArgumentException.class,
+                () -> TSOTimestamp.composePhysicalTimestamp(-1L));
+        Assertions.assertThrows(ArithmeticException.class,
+                () -> TSOTimestamp.composePhysicalTimestamp(maxPhysicalTime + 
1));
+        Assertions.assertThrows(IllegalArgumentException.class,
+                () -> TSOTimestamp.composePhysicalTimestamp(1L << 46));
+    }
+
     @Test
     public void testBitWidthLimitations() {
         // Test that values are properly masked to fit in their respective bit 
widths


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

Reply via email to