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

morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new bc49fd106e3 [fix](ivm) Read a base partition with no stream offset 
from the base table in an IVM refresh (#69778)
bc49fd106e3 is described below

commit bc49fd106e36bcb54576a3c914dc3e23ad401b09
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Thu Oct 29 03:59:39 2026 +0000

    [fix](ivm) Read a base partition with no stream offset from the base table 
in an IVM refresh (#69778)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: None
    
    Problem Summary:
    
    **In short.** An IVM materialized view silently misses the rows of a
    base partition its internal stream has no offset for (a partition added
    after the MV was built, or one that was still empty then) when those
    rows are older than the base table's `changelog.ttl_seconds` at refresh
    time. The refresh succeeds, the rows are never read, and a base-table
    query of the MV's shape that transparent rewrite answers from the MV
    returns the same wrong result. This PR makes an IVM read take such a
    partition from the base table, as an append of all its rows, instead of
    from its changelog.
    
    **Background.** An IVM MV reads each base table through an internal
    table stream. The stream keeps an offset per partition and records one
    only when it consumes the partition with data in it. It is created with
    `show_initial_rows`, so a partition that has data at creation is a
    historical partition, read once from the base table; a partition that is
    empty then gets nothing; and every consumption, the reset of a COMPLETE
    refresh included, records offsets only for the partitions it read with
    data. Two kinds of partitions therefore have no offset and none of their
    rows in the MV: a partition added after the stream was created, and a
    partition that was empty when the stream was created and at every
    consumption since (a range partition created ahead of its data is the
    usual one).
    
    **The problem, and what it cost.**
    - `NormalizeOlapTableStreamScan.makeTableStreamScan` reads a partition
    with no offset like any other incremental partition: from its changelog,
    with no start. `applyChangelogTtl` turns the missing start into the
    first TSO the changelog retains and does not reject it.
    - Once the partition's rows are older than the TTL when the MV is
    refreshed, the read skips them. The refresh succeeds and records the
    partition's offset past them, so no later refresh reads them either: the
    MV stays short of those rows for good.
    - This holds for an MV over a unique-key table and for an append-only
    detail MV over a duplicate-key table. An MV partitioned like its base
    table rebuilds the MV partition of an added base partition, but its
    partition that was empty when the MV was built is caught up
    incrementally and loses its rows the same way.
    
    **How this PR fixes it.**
    - In an IVM statement, `makeTableStreamScan` moves the incremental
    partitions that have no consumption baseline
    (`OlapTableStreamWrapper.filterConsumedPartitionIds`: no offset, or the
    sentinel offset of a partition empty at stream creation) to the branch
    that reads historical partitions from the base table: every row as an
    append, with the commit TSO as the sequence and the row LSN as the row
    identity. For an MV that holds none of the partition's rows that is the
    partition's whole change, whatever the TTL. The offset the refresh
    records afterwards is the same as before.
    - The base read and the end TSO the refresh commits come from the same
    snapshot, as for a historical partition: in shared-nothing mode both are
    taken under the table read lock that planning holds, and in cloud mode
    the base read is pinned to the visible version of the stream read state.
    - A user stream is not changed: reading the changelog it retains, from
    the first retained TSO, is what it promises. The choice is made by the
    statement being an IVM rewrite, not by the stream.
    
    **Results** (local shared-nothing cluster; rows written to an added
    partition and to the partition that was empty, older than a one-second
    TTL when the MV is refreshed):
    
    | MV | Before | After |
    | --- | --- | --- |
    | non-partitioned, over a unique-key table | SUCCESS, the rows of both
    partitions missing for good | SUCCESS, the MV matches the base table |
    | append-only detail MV, over a duplicate-key table | SUCCESS, the rows
    of both partitions missing for good | SUCCESS, the MV matches the base
    table |
    | partitioned like its unique-key base table | SUCCESS, the added
    partition rebuilt, the rows of the one that was empty missing | SUCCESS,
    the MV matches the base table |
    | the next refresh, after more changes, with the TTL back to a day |
    incremental, the rows still missing | incremental, the new changes read
    from the changelog |
    
    **Classes, and how they call each other.**
    - `IvmIncrRefreshMTMV` → `IvmDeltaRewriteState.createDeltaScan` builds a
    `LogicalOlapTableStreamScan` over an `OlapTableStreamWrapper`.
    - `NormalizeOlapTableStreamScan.makeTableStreamScan` →
    `filterHistoryPartitionIds` / `filterIncrementalPartitionIds`, and in an
    IVM statement `filterConsumedPartitionIds` → historical and unconsumed
    partitions: `makeOlapScanOnBaseTable`; consumed partitions:
    `makeIncrementalScanFromChangelog` → `applyChangelogTtl`.
    - `StreamConsumptionInfoExtractor` records the new offsets from the
    wrapper's output map, as before.
    
    Not changed: the TTL check, the snapshot and reset reads (the snapshot
    read already leaves the partitions without a baseline out and reports
    that it did), user streams, and anything persisted.
---
 .../rewrite/NormalizeOlapTableStreamScan.java      |  19 +-
 .../trees/plans/ExplainTableStreamPlanTest.java    | 103 ++++++++--
 .../ivm/test_ivm_partition_without_offset.out      |  73 +++++++
 .../ivm/test_ivm_partition_without_offset.groovy   | 213 +++++++++++++++++++++
 4 files changed, 395 insertions(+), 13 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NormalizeOlapTableStreamScan.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NormalizeOlapTableStreamScan.java
index 30ae919fc07..6b8092c9f97 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NormalizeOlapTableStreamScan.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/NormalizeOlapTableStreamScan.java
@@ -377,7 +377,8 @@ public class NormalizeOlapTableStreamScan extends 
OneRewriteRuleFactory {
      * <p>Selected partitions are split into:
      * <ul>
      *   <li>historical partitions — never consumed history data; scanned from 
the base table and all
-     *       rows are treated as APPEND (change type = "APPEND", seq = commit 
tso, lsn = row lsn);</li>
+     *       rows are treated as APPEND (change type = "APPEND", seq = commit 
tso, lsn = row lsn). An IVM
+     *       read adds the incremental partitions that have no consumption 
baseline;</li>
      *   <li>incremental partitions — read row-level changes from changelog via
      *       {@link #makeIncrementalScanFromChangelog}.</li>
      * </ul>
@@ -390,6 +391,22 @@ public class NormalizeOlapTableStreamScan extends 
OneRewriteRuleFactory {
         List<Long> historicalPartitionIds = 
streamWrapper.filterHistoryPartitionIds(scan.getSelectedPartitionIds());
         List<Long> incrementalPartitionIds =
                 
streamWrapper.filterIncrementalPartitionIds(scan.getSelectedPartitionIds());
+        if (cascadesContext.getStatementContext().isIvmMTMVRewrite()) {
+            // A partition with no consumption baseline -- added after the 
stream was created, or still empty when
+            // the stream was created and whenever it was consumed -- has none 
of its rows in the MV yet. Read from
+            // its changelog, it would be read from the first retained TSO, 
and the rows older than the TTL would
+            // be left out while the refresh succeeds. An IVM read takes such 
a partition from the base table, the
+            // way it takes a historical one. A user stream keeps reading the 
changelog it retains.
+            Set<Long> consumedIds = ImmutableSet.copyOf(
+                    
streamWrapper.filterConsumedPartitionIds(incrementalPartitionIds));
+            historicalPartitionIds = ImmutableList.<Long>builder()
+                    .addAll(historicalPartitionIds)
+                    .addAll(incrementalPartitionIds.stream().filter(id -> 
!consumedIds.contains(id)).iterator())
+                    .build();
+            incrementalPartitionIds = incrementalPartitionIds.stream()
+                    .filter(consumedIds::contains)
+                    .collect(ImmutableList.toImmutableList());
+        }
         Plan historyPlan = null;
         Plan incrementalPlan = null;
         List<Slot> originSlots = scan.getOutput();
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 2b9e4f4ca92..1ff0b84099f 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
@@ -23,10 +23,12 @@ import org.apache.doris.analysis.Expr;
 import org.apache.doris.catalog.Column;
 import org.apache.doris.catalog.Database;
 import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.MTMV;
 import org.apache.doris.catalog.MaterializedIndex;
 import org.apache.doris.catalog.MaterializedIndex.IndexExtState;
 import org.apache.doris.catalog.OlapChangelogTableWrapper;
 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.Tablet;
@@ -38,6 +40,7 @@ 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.mtmv.ivm.IvmRewriteContext;
 import org.apache.doris.nereids.NereidsPlanner;
 import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.exceptions.AnalysisException;
@@ -45,6 +48,7 @@ import 
org.apache.doris.nereids.glue.translator.PhysicalPlanTranslator;
 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.rules.rewrite.NormalizeOlapTableStreamScan;
 import org.apache.doris.nereids.trees.expressions.Alias;
 import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.expressions.LessThan;
@@ -75,12 +79,15 @@ import org.apache.doris.thrift.TScanRangeLocations;
 import org.apache.doris.tso.TSOTimestamp;
 import org.apache.doris.utframe.TestWithFeService;
 
+import com.google.common.collect.ImmutableSet;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
 
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 /**
@@ -136,6 +143,21 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
         createTable(createIncrementalStream);
         bumpPartitionsAndReplicas(baseTable, 2002L);
 
+        // A stream with a partition that has no consumption baseline: p2 is 
still empty when the stream is
+        // created, so it is recorded with the sentinel offset, and only gets 
data afterwards. p1 has data
+        // before and after, so it has a real offset and data after it.
+        createTable("create table test_stream.tbl_stream_unconsumed (k1 int, 
k2 int not null) unique key(k1)\n"
+                + "partition by range(k1) (partition p1 values less than 
(\"100\"),"
+                + " partition p2 values less than (\"200\"))\n"
+                + "distributed by hash(k1) buckets 1 
properties(\"replication_num\"=\"1\","
+                + 
"\"enable_unique_key_merge_on_write\"=\"true\",\"changelog.enable\"=\"true\","
+                + 
"\"changelog.ttl_seconds\"=\"9223372036854775807\",\"changelog.need_historical_value\"=\"true\")");
+        OlapTable unconsumedBaseTable = (OlapTable) 
db.getTableOrMetaException("tbl_stream_unconsumed");
+        bumpPartitionAndReplicas(unconsumedBaseTable.getPartition("p1"), 
1001L);
+        createTable("create stream if not exists test_stream.s_unconsumed on 
table test_stream.tbl_stream_unconsumed"
+                + " properties('show_initial_rows' = 'false')");
+        bumpPartitionsAndReplicas(unconsumedBaseTable, 2002L);
+
         String createDuplicateBaseTable = "create table 
test_stream.tbl_dup_stream_base (\n"
                 + "  k1 int,\n"
                 + "  k2 int not null\n"
@@ -164,18 +186,22 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
 
     private static void bumpPartitionsAndReplicas(OlapTable table, long 
newVersion) {
         for (Partition partition : table.getPartitions()) {
-            // Use the same timestamp value as both visibleVersionTime and tso 
to simulate a
-            // transactional commit advancing partition.tso (the dedicated 
commit-tso field).
-            // Without populating tso explicitly, non-transactional setters 
leave it as -1
-            // which would make the stream consider the partition fully 
consumed.
-            long ts = System.currentTimeMillis();
-            partition.setVisibleVersionAndTime(newVersion, ts, ts);
-            partition.setNextVersion(newVersion + 1);
-            for (MaterializedIndex index : 
partition.getMaterializedIndices(IndexExtState.VISIBLE, true)) {
-                for (Tablet tablet : index.getTablets()) {
-                    for (Replica replica : tablet.getReplicas()) {
-                        replica.updateVersion(newVersion);
-                    }
+            bumpPartitionAndReplicas(partition, newVersion);
+        }
+    }
+
+    private static void bumpPartitionAndReplicas(Partition partition, long 
newVersion) {
+        // Use the same timestamp value as both visibleVersionTime and tso to 
simulate a
+        // transactional commit advancing partition.tso (the dedicated 
commit-tso field).
+        // Without populating tso explicitly, non-transactional setters leave 
it as -1
+        // which would make the stream consider the partition fully consumed.
+        long ts = System.currentTimeMillis();
+        partition.setVisibleVersionAndTime(newVersion, ts, ts);
+        partition.setNextVersion(newVersion + 1);
+        for (MaterializedIndex index : 
partition.getMaterializedIndices(IndexExtState.VISIBLE, true)) {
+            for (Tablet tablet : index.getTablets()) {
+                for (Replica replica : tablet.getReplicas()) {
+                    replica.updateVersion(newVersion);
                 }
             }
         }
@@ -311,6 +337,59 @@ public class ExplainTableStreamPlanTest extends 
TestWithFeService {
         }
     }
 
+    @Test
+    public void testUserStreamReadsPartitionWithoutBaselineFromChangelog() 
throws Exception {
+        // A user stream reads every partition with data after its offset from 
the changelog, the one without a
+        // consumption baseline included: from the first retained TSO, which 
is what the stream retains.
+        OlapTable baseTable = (OlapTable) ((Database) 
Env.getCurrentInternalCatalog()
+                
.getDbOrMetaException("test_stream")).getTableOrMetaException("tbl_stream_unconsumed");
+        Plan plan = normalizeStreamRead("select * from 
test_stream.s_unconsumed",
+                
TSOTimestamp.composePhysicalTimestamp(System.currentTimeMillis()), 
Optional.empty());
+
+        
Assertions.assertEquals(ImmutableSet.of(baseTable.getPartition("p1").getId(),
+                baseTable.getPartition("p2").getId()), 
scannedPartitionIds(plan, true));
+        Assertions.assertEquals(ImmutableSet.of(), scannedPartitionIds(plan, 
false));
+    }
+
+    @Test
+    public void testIvmReadsPartitionWithoutBaselineFromBaseTable() throws 
Exception {
+        // The same read inside an IVM refresh takes the partition without a 
consumption baseline from the base
+        // table: none of its rows are in the MV yet, and its changelog may no 
longer reach back to its first row.
+        // The partition with an offset is still read from the changelog.
+        OlapTable baseTable = (OlapTable) ((Database) 
Env.getCurrentInternalCatalog()
+                
.getDbOrMetaException("test_stream")).getTableOrMetaException("tbl_stream_unconsumed");
+        Plan plan = normalizeStreamRead("select * from 
test_stream.s_unconsumed",
+                
TSOTimestamp.composePhysicalTimestamp(System.currentTimeMillis()),
+                
Optional.of(IvmRewriteContext.incremental(Mockito.mock(MTMV.class))));
+
+        
Assertions.assertEquals(ImmutableSet.of(baseTable.getPartition("p1").getId()),
+                scannedPartitionIds(plan, true));
+        
Assertions.assertEquals(ImmutableSet.of(baseTable.getPartition("p2").getId()),
+                scannedPartitionIds(plan, false));
+    }
+
+    // The partitions the plan reads from the changelog, or from the base 
table itself.
+    private static Set<Long> scannedPartitionIds(Plan plan, boolean changelog) 
{
+        Set<LogicalOlapScan> scans = plan.collect(node -> node instanceof 
LogicalOlapScan
+                && (changelog ? ((LogicalOlapScan) node).getTable() instanceof 
OlapChangelogTableWrapper
+                        : !(((LogicalOlapScan) node).getTable() instanceof 
OlapTableWrapper)));
+        ImmutableSet.Builder<Long> partitionIds = ImmutableSet.builder();
+        scans.forEach(scan -> 
partitionIds.addAll(scan.getSelectedPartitionIds()));
+        return partitionIds.build();
+    }
+
+    private Plan normalizeStreamRead(String sql, long referenceTso, 
Optional<IvmRewriteContext> ivmRewriteContext) {
+        PlanChecker checker = PlanChecker.from(connectContext).parse(sql);
+        StatementContext statementContext = 
checker.getCascadesContext().getStatementContext();
+        // Direct rewrite tests bypass the planner's statement reference 
initialization.
+        statementContext.getOrRegisterChangelogReferenceTso(() -> 
referenceTso);
+        checker.analyze();
+        // Installed after the analysis, whose IVM rules would rewrite the 
query as an MV's: only how the stream
+        // read treats its offset inside an IVM statement is under test.
+        statementContext.setIvmRewriteContext(ivmRewriteContext);
+        return checker.applyTopDown(new 
NormalizeOlapTableStreamScan()).getCascadesContext().getRewritePlan();
+    }
+
     @Test
     public void testIncrementalScanChangeTypeProjectionIsCaseExpr() throws 
Exception {
         ConnectContext ctx = createDefaultCtx();
diff --git 
a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_without_offset.out 
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_without_offset.out
new file mode 100644
index 00000000000..f8649546dcb
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_without_offset.out
@@ -0,0 +1,73 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !u_baseline_task --
+SUCCESS        COMPLETE        NONE
+
+-- !d_baseline_task --
+SUCCESS        COMPLETE        NONE
+
+-- !p_baseline_task --
+SUCCESS        COMPLETE        NONE
+
+-- !u_task --
+SUCCESS        NONE    NONE
+
+-- !u_mv --
+1      2026-01-10      10
+2      2026-02-10      20
+3      2026-03-10      30
+
+-- !d_task --
+SUCCESS        NONE    NONE
+
+-- !d_mv --
+1      2026-01-10      10
+2      2026-02-10      20
+3      2026-03-10      30
+
+-- !p_task --
+SUCCESS        PARTIAL NONE
+
+-- !p_mv --
+1      2026-01-10      10
+2      2026-02-10      20
+3      2026-03-10      30
+
+-- !u_task_after_offsets --
+SUCCESS        NONE    NONE
+
+-- !u_mv_after_offsets --
+1      2026-01-10      10
+2      2026-02-10      21
+3      2026-03-10      30
+4      2026-03-11      40
+
+-- !d_task_after_offsets --
+SUCCESS        NONE    NONE
+
+-- !d_mv_after_offsets --
+1      2026-01-10      10
+2      2026-02-10      20
+3      2026-03-10      30
+4      2026-03-11      40
+
+-- !p_task_after_offsets --
+SUCCESS        NONE    NONE
+
+-- !p_mv_after_offsets --
+1      2026-01-10      10
+2      2026-02-10      21
+3      2026-03-10      30
+4      2026-03-11      40
+
+-- !base_u --
+1      2026-01-10      10
+2      2026-02-10      21
+3      2026-03-10      30
+4      2026-03-11      40
+
+-- !base_d --
+1      2026-01-10      10
+2      2026-02-10      20
+3      2026-03-10      30
+4      2026-03-11      40
+
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_without_offset.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_without_offset.groovy
new file mode 100644
index 00000000000..34ff7ed7a35
--- /dev/null
+++ 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_without_offset.groovy
@@ -0,0 +1,213 @@
+// 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.
+
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+/**
+ * An incremental refresh that reads a base partition the stream has no offset 
for: one added after the MV was
+ * built, and one that was still empty then.
+ *
+ * <p>A stream records an offset for a partition when it consumes the 
partition with data in it, so neither of
+ * the two has one, and none of their rows are in the MV. Read through the 
changelog, such a partition is read
+ * from the first TSO the changelog retains, and the rows older than 
changelog.ttl_seconds would be left out
+ * while the refresh succeeds. The refresh reads it from the base table 
instead, as an append of all its rows.
+ *
+ * <p>Cases pinned here, for an MV over a unique-key table, an append-only 
detail MV over a duplicate-key table
+ * and an MV partitioned like its unique-key base table:
+ * <ol>
+ *   <li>the rows written to the two partitions are older than the TTL when 
the MV is refreshed: a strict
+ *       INCREMENTAL refresh succeeds and the MV has them;</li>
+ *   <li>with the TTL back to a day, the two partitions have offsets now, and 
the next refresh reads their
+ *       changes from the changelog;</li>
+ *   <li>the partitioned MV rebuilds the MV partition of the added base 
partition, as before.</li>
+ * </ol>
+ *
+ * <p>nonConcurrent: the rows age past a one-second TTL by sleeping.
+ */
+suite("test_ivm_partition_without_offset", "nonConcurrent") {
+    def waitForNewTask = { String mvName, previousTaskId ->
+        def taskResult
+        Awaitility.await().atMost(300, SECONDS).pollInterval(2, 
SECONDS).until({
+            taskResult = sql_return_maparray("""
+                SELECT TaskId, Status
+                FROM tasks('type'='mv')
+                WHERE MvDatabaseName = '${context.dbName}'
+                  AND MvName = '${mvName}'
+                ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+            """)
+            return !taskResult.isEmpty()
+                    && taskResult[0].TaskId.toString() != previousTaskId
+                    && taskResult[0].Status.toString() != 'PENDING'
+                    && taskResult[0].Status.toString() != 'RUNNING'
+        })
+        return taskResult[0]
+    }
+
+    // How a refresh ended, in one row. Unset RefreshMode / IvmFallbackReason 
come back as the literal
+    // two-character string "\N", which does not survive the .out round trip, 
so fold them into a printable
+    // token; a reason is an enum name, which "\N" is not.
+    def taskQuery = { String taskId ->
+        """
+            SELECT Status,
+                   CASE WHEN RefreshMode IN ('COMPLETE', 'PARTIAL', 
'NOT_REFRESH')
+                        THEN RefreshMode ELSE 'NONE' END,
+                   CASE WHEN IvmFallbackReason REGEXP '^[A-Z_]+\$'
+                        THEN IvmFallbackReason ELSE 'NONE' END
+            FROM tasks('type'='mv')
+            WHERE TaskId = '${taskId}'
+        """
+    }
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS ivm_no_offset_u_mv"""
+    sql """DROP MATERIALIZED VIEW IF EXISTS ivm_no_offset_d_mv"""
+    sql """DROP MATERIALIZED VIEW IF EXISTS ivm_no_offset_u_pmv"""
+    sql """DROP TABLE IF EXISTS ivm_no_offset_u"""
+    sql """DROP TABLE IF EXISTS ivm_no_offset_d"""
+
+    sql """
+        CREATE TABLE ivm_no_offset_u (
+            k BIGINT NOT NULL,
+            dt DATE NOT NULL,
+            v BIGINT
+        )
+        UNIQUE KEY(k, dt)
+        PARTITION BY RANGE(dt) (
+            PARTITION p202601 VALUES [('2026-01-01'), ('2026-02-01')),
+            PARTITION p202602 VALUES [('2026-02-01'), ('2026-03-01'))
+        )
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1",
+            "enable_unique_key_merge_on_write" = "true",
+            "changelog.enable" = "true",
+            "changelog.need_historical_value" = "true",
+            "changelog.ttl_seconds" = "86400"
+        )
+    """
+    sql """
+        CREATE TABLE ivm_no_offset_d (
+            k BIGINT NOT NULL,
+            dt DATE NOT NULL,
+            v BIGINT
+        )
+        DUPLICATE KEY(k)
+        PARTITION BY RANGE(dt) (
+            PARTITION p202601 VALUES [('2026-01-01'), ('2026-02-01')),
+            PARTITION p202602 VALUES [('2026-02-01'), ('2026-03-01'))
+        )
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1",
+            "changelog.enable" = "true",
+            "changelog.ttl_seconds" = "86400"
+        )
+    """
+    // p202602 stays empty until after the MVs are built.
+    sql """INSERT INTO ivm_no_offset_u VALUES (1, '2026-01-10', 10)"""
+    sql """INSERT INTO ivm_no_offset_d VALUES (1, '2026-01-10', 10)"""
+
+    sql """
+        CREATE MATERIALIZED VIEW ivm_no_offset_u_mv
+        BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+        AS SELECT k, dt, v FROM ivm_no_offset_u
+    """
+    sql """
+        CREATE MATERIALIZED VIEW ivm_no_offset_d_mv
+        BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+        AS SELECT k, dt, v FROM ivm_no_offset_d
+    """
+    sql """
+        CREATE MATERIALIZED VIEW ivm_no_offset_u_pmv
+        BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+        KEY(k, dt)
+        PARTITION BY(dt)
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+        AS SELECT k, dt, v FROM ivm_no_offset_u
+    """
+
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_u_mv INCREMENTAL"""
+    def uTask = waitForNewTask('ivm_no_offset_u_mv', null)
+    qt_u_baseline_task taskQuery(uTask.TaskId.toString())
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_d_mv INCREMENTAL"""
+    def dTask = waitForNewTask('ivm_no_offset_d_mv', null)
+    qt_d_baseline_task taskQuery(dTask.TaskId.toString())
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_u_pmv INCREMENTAL"""
+    def pTask = waitForNewTask('ivm_no_offset_u_pmv', null)
+    qt_p_baseline_task taskQuery(pTask.TaskId.toString())
+
+    // 1. Rows in the partition that was empty when the MVs were built and in 
a partition added after that, both
+    // older than the TTL by the time the MVs are refreshed. Nothing else 
changes, so no offset is read.
+    sql """ALTER TABLE ivm_no_offset_u ADD PARTITION p202603 VALUES 
[('2026-03-01'), ('2026-04-01'))"""
+    sql """ALTER TABLE ivm_no_offset_d ADD PARTITION p202603 VALUES 
[('2026-03-01'), ('2026-04-01'))"""
+    sql """INSERT INTO ivm_no_offset_u VALUES (2, '2026-02-10', 20), (3, 
'2026-03-10', 30)"""
+    sql """INSERT INTO ivm_no_offset_d VALUES (2, '2026-02-10', 20), (3, 
'2026-03-10', 30)"""
+    sql """ALTER TABLE ivm_no_offset_u SET ("changelog.ttl_seconds" = "1")"""
+    sql """ALTER TABLE ivm_no_offset_d SET ("changelog.ttl_seconds" = "1")"""
+    sleep(1500)
+
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_u_mv INCREMENTAL"""
+    uTask = waitForNewTask('ivm_no_offset_u_mv', uTask.TaskId.toString())
+    qt_u_task taskQuery(uTask.TaskId.toString())
+    order_qt_u_mv """SELECT k, dt, v FROM ivm_no_offset_u_mv"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_d_mv INCREMENTAL"""
+    dTask = waitForNewTask('ivm_no_offset_d_mv', dTask.TaskId.toString())
+    qt_d_task taskQuery(dTask.TaskId.toString())
+    order_qt_d_mv """SELECT k, dt, v FROM ivm_no_offset_d_mv"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_u_pmv INCREMENTAL"""
+    pTask = waitForNewTask('ivm_no_offset_u_pmv', pTask.TaskId.toString())
+    qt_p_task taskQuery(pTask.TaskId.toString())
+    order_qt_p_mv """SELECT k, dt, v FROM ivm_no_offset_u_pmv"""
+
+    // 2. With the TTL back to a day, the offsets the refreshes above recorded 
are well inside the retention, and
+    // what changes after them is read from the changelog: an update and a new 
row in the unique-key table, a
+    // new row in the duplicate-key one.
+    sql """ALTER TABLE ivm_no_offset_u SET ("changelog.ttl_seconds" = 
"86400")"""
+    sql """ALTER TABLE ivm_no_offset_d SET ("changelog.ttl_seconds" = 
"86400")"""
+    sql """INSERT INTO ivm_no_offset_u VALUES (2, '2026-02-10', 21), (4, 
'2026-03-11', 40)"""
+    sql """INSERT INTO ivm_no_offset_d VALUES (4, '2026-03-11', 40)"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_u_mv INCREMENTAL"""
+    uTask = waitForNewTask('ivm_no_offset_u_mv', uTask.TaskId.toString())
+    qt_u_task_after_offsets taskQuery(uTask.TaskId.toString())
+    order_qt_u_mv_after_offsets """SELECT k, dt, v FROM ivm_no_offset_u_mv"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_d_mv INCREMENTAL"""
+    dTask = waitForNewTask('ivm_no_offset_d_mv', dTask.TaskId.toString())
+    qt_d_task_after_offsets taskQuery(dTask.TaskId.toString())
+    order_qt_d_mv_after_offsets """SELECT k, dt, v FROM ivm_no_offset_d_mv"""
+
+    sql """REFRESH MATERIALIZED VIEW ivm_no_offset_u_pmv INCREMENTAL"""
+    pTask = waitForNewTask('ivm_no_offset_u_pmv', pTask.TaskId.toString())
+    qt_p_task_after_offsets taskQuery(pTask.TaskId.toString())
+    order_qt_p_mv_after_offsets """SELECT k, dt, v FROM ivm_no_offset_u_pmv"""
+
+    // What the base tables answer for the MV queries. Rewrite is off: a query 
of the MV's shape would otherwise
+    // be answered from an MV, and the comparison would compare the MV with 
itself.
+    sql """SET enable_materialized_view_rewrite = false"""
+    order_qt_base_u """SELECT k, dt, v FROM ivm_no_offset_u"""
+    order_qt_base_d """SELECT k, dt, v FROM ivm_no_offset_d"""
+    sql """SET enable_materialized_view_rewrite = true"""
+}


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

Reply via email to