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

yujun777 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 f8ed33fa701 [fix](ivm) Refresh the surviving partitions after an IVM 
baseline rebuild (#67802)
f8ed33fa701 is described below

commit f8ed33fa70133b7c0dac0c7178dedc8d76e8b535
Author: yujun <[email protected]>
AuthorDate: Fri Sep 11 17:21:38 2026 +0800

    [fix](ivm) Refresh the surviving partitions after an IVM baseline rebuild 
(#67802)
    
    ### What problem does this PR solve?
    
    Dropping a base-table partition marks the IVM baseline as broken,
    because those rows disappear through metadata rather than through row
    binlog entries. Partition sync then removes the very MV partitions that
    barrier named, so `handlePendingIvmBaselineRebuild` intersected the
    barrier with the current MV partitions and always got an empty set. The
    refresh reported SUCCESS with refresh mode `NOT_REFRESH` and cleared the
    barrier without refreshing anything, leaving the delta that had
    accumulated on the surviving partitions unapplied: the task claimed the
    MV was up to date while the MV was missing rows.
    
    Reproduction: drop a base partition, insert a row into a surviving
    partition, then run `REFRESH MATERIALIZED VIEW ... INCREMENTAL
    FALLBACK`. The task succeeds, the expired MV partition is dropped, and
    the new row never reaches the MV.
    
    ### What changed
    
    - `MTMVTask.handlePendingIvmBaselineRebuild` is a pre-step now instead
    of a terminal branch. It rebuilds only the barrier partitions that still
    exist, releases the barrier, and then lets the normal attempt list run,
    so the surviving partitions catch up in the same task. Partitions the
    barrier named that partition sync already dropped need no rebuild: the
    partition and its IVM offsets are both gone, which is what satisfies
    those entries.
    - The attempt list is rewritten in place. A lone `COMPLETE` attempt
    rebuilds everything anyway and needs no pre-rebuild; a complete baseline
    rebuild rewrites the list to `COMPLETE` instead of executing it inline.
    - `MTMV.releaseIvmBaselineRebuild` releases the barrier under a
    `schemaChangeVersion` compare-and-clear and journals the new state
    immediately, like the other `ivmInfo` mutations. Without the release the
    IVM attempt that follows would be rejected by `validateIvmRefreshStart`;
    without the compare-and-clear a concurrent base-table change would lose
    the barrier entry it had just recorded.
    - `MTMVRelationManager.markIvmBaselineRebuild` takes an explicit
    all-partitions-changed flag instead of inferring it from an empty
    partition map.
    
    The COMPLETE paths are unchanged. They end the task, so the existing
    `addTaskResult` cleanup releases the barrier once the refresh succeeds.
    
    A strict `REFRESH ... INCREMENTAL`, and a scheduled refresh of an MV
    declared without `FALLBACK`, still fail while the barrier is pending,
    because that check runs before partition sync. Relaxing it is left to a
    follow-up PR.
    
    ### Test
    
    -
    
`regression-test/suites/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.groovy`
    (new): partitions added and dropped by hand with literal dates and no
    dynamic-partition scheduler, so the case is deterministic. Asserts the
    MV matches the base table right after `INCREMENTAL FALLBACK`. The
    scenario was reproduced on the pre-fix build, where the fallback task
    reported SUCCESS while the MV was missing the row written to the
    surviving partition.
    -
    
`regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.groovy`
    (new): covers the `TRUNCATE` path, where the affected MV partition
    survives and the pre-step really has something to rebuild. Runs on a
    duplicate-key MV with repeated identical rows, so a delta applied twice
    would show up as extra rows instead of hiding behind a unique key.
    - `test_ivm_partition_baseline_rebuild`,
    `test_ivm_partition_sync_retry`, `test_ivm_partition_unique_key`,
    `test_ivm_partition_window_limit`, `test_ivm_partition_window_remove`,
    `test_ivm_one_row_relation_partitioned`,
    `test_ivm_strict_failure_partition_atomicity`
    
    Trace issue: https://github.com/apache/doris/issues/65418
---
 .../main/java/org/apache/doris/catalog/MTMV.java   |  35 ++++++
 .../apache/doris/job/extensions/mtmv/MTMVTask.java |  56 ++++++---
 .../org/apache/doris/mtmv/MTMVRelationManager.java |   8 +-
 .../java/org/apache/doris/mtmv/MTMVTaskTest.java   |  42 ++++++-
 ...est_ivm_partition_baseline_rebuild_dup_keys.out |  32 +++++
 .../ivm/test_ivm_partition_drop_live_delta.out     |  45 +++++++
 ..._ivm_partition_baseline_rebuild_dup_keys.groovy | 123 +++++++++++++++++++
 .../ivm/test_ivm_partition_drop_live_delta.groovy  | 134 +++++++++++++++++++++
 8 files changed, 452 insertions(+), 23 deletions(-)

diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
index 354081ef5b6..b162a2b2d51 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
@@ -645,6 +645,41 @@ public class MTMV extends OlapTable {
         editLogItem.await();
     }
 
+    /**
+     * Release the IVM baseline barrier after the partitions it named have 
been rebuilt, or after
+     * partition sync removed them (a dropped partition resolves its own 
entry: the partition and its
+     * IVM offsets are both gone).
+     *
+     * <p>Guarded by schemaChangeVersion, like {@link 
#persistIvmBaselineGuard}: a base-table change
+     * landing while the rebuild runs carries its own barrier entry, and a 
blind clear would swallow
+     * it. Failing instead preserves that entry -- the next refresh rebuilds 
it together with the
+     * partitions this task handled.
+     *
+     * <p>Journals the new state right away, like every other ivmInfo mutation 
here. A task that dies
+     * before {@link #addTaskResult} would otherwise leave the release in 
memory only, and a restart
+     * would resurrect the barrier from disk.
+     */
+    public void releaseIvmBaselineRebuild(long expectedSchemaChangeVersion) 
throws JobException {
+        EditLogItem editLogItem;
+        writeMvLock();
+        try {
+            if (ivmInfo == null || !ivmInfo.isBaselineRebuildRequired()) {
+                // Nothing to release: skip both the mutation and the journal 
entry. Any base-table
+                // change that raced us in is still caught by 
validateIvmRefreshStart() below.
+                return;
+            }
+            if (schemaChangeVersion != expectedSchemaChangeVersion) {
+                throw new JobException("Base table metadata changed before IVM 
baseline refresh, mv="
+                        + getName());
+            }
+            ivmInfo.clearBaselineRebuild();
+            editLogItem = submitIvmInfoChange();
+        } finally {
+            writeMvUnlock();
+        }
+        editLogItem.await();
+    }
+
     public void persistIvmBaselineGuard(RefreshMode refreshMode, Set<String> 
baselinePartitions,
             long expectedSchemaChangeVersion) throws JobException {
         EditLogItem editLogItem;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
index d97e142fee9..1eab83fe576 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
@@ -319,9 +319,7 @@ public class MTMVTask extends AbstractTask {
                 throw new JobException(e.getMessage(), e);
             }
             MTMVRefreshContext refreshContext = buildRefreshContext(tableIfs);
-            if (handlePendingIvmBaselineRebuild(refreshContext, request, ctx)) 
{
-                return;
-            }
+            handlePendingIvmBaselineRebuild(refreshContext, request, ctx, 
attempts);
             boolean disablePartitionRefresh = false;
             for (RefreshAttemptType attemptType : attempts) {
                 switch (attemptType) {
@@ -558,28 +556,56 @@ public class MTMVTask extends AbstractTask {
         executePartitionBasedRefresh(context, RefreshMode.COMPLETE, ctx);
     }
 
-    private boolean handlePendingIvmBaselineRebuild(MTMVRefreshContext 
context, RefreshRequest request,
-            ConnectContext ctx)
+    /**
+     * Rebuild the MV partitions whose IVM baseline is broken, before the 
normal refresh runs.
+     *
+     * <p>This is a pre-step, not a terminal branch: the caller keeps running 
{@code attempts}
+     * afterwards, so a broken baseline no longer skips the refresh entirely. 
The list is rewritten
+     * in place when the baseline demands a different set of attempts.
+     *
+     * <p>Partition sync drops the MV partitions whose base partition 
disappeared, which is exactly
+     * what the barrier recorded when that base partition was dropped. Those 
partitions are resolved
+     * by the drop itself (the partition and its IVM offsets are both gone), 
so only the partitions
+     * that still exist need a rebuild. The barrier is released either way, 
otherwise the IVM attempt
+     * that follows would be rejected by {@link MTMV#validateIvmRefreshStart}.
+     */
+    private void handlePendingIvmBaselineRebuild(MTMVRefreshContext context,
+            RefreshRequest request, ConnectContext ctx, 
List<RefreshAttemptType> attempts)
             throws JobException, AnalysisException {
         if (!mtmv.isIvm() || request.refreshMode == RefreshMode.COMPLETE
                 || !mtmv.getIvmInfo().isBaselineRebuildRequired()) {
-            return false;
+            return;
         }
         ivmFallbackReason = IvmFailureReason.BINLOG_BROKEN.name();
         IvmInfo ivmInfo = mtmv.getIvmInfo();
+        // A lone COMPLETE attempt rebuilds every partition anyway, so a 
partial pre-rebuild here
+        // would be redundant; it also releases the barrier by itself once it 
succeeds.
+        if (attempts.size() == 1 && attempts.get(0) == 
RefreshAttemptType.COMPLETE) {
+            LOG.info("IVM baseline barrier is covered by the pending COMPLETE 
attempt, mv={}, taskId={}",
+                    mtmv.getName(), getTaskId());
+            return;
+        }
         if (ivmInfo.requiresCompleteBaselineRebuild()) {
-            executeCompleteAttempt(context, ctx);
-            return true;
+            LOG.warn("IVM baseline requires a complete rebuild, mv={}, 
taskId={}. "
+                    + "Continuing with COMPLETE refresh.", mtmv.getName(), 
getTaskId());
+            attempts.clear();
+            attempts.add(RefreshAttemptType.COMPLETE);
+            return;
         }
-        this.needRefreshPartitions = Lists.newArrayList(Sets.intersection(
+        List<String> baselinePartitions = Lists.newArrayList(Sets.intersection(
                 ivmInfo.getPendingBaselineRebuildPartitions(), 
mtmv.getPartitionNames()));
-        this.needRefreshPartitions.sort(String::compareTo);
-        this.refreshMode = generateRefreshMode(needRefreshPartitions);
-        if (refreshMode == MTMVTaskRefreshMode.NOT_REFRESH) {
-            return true;
+        if (baselinePartitions.isEmpty()) {
+            // Partition sync has already dropped every partition the barrier 
named, so there is
+            // nothing left to rebuild. The surviving partitions are picked up 
by the attempts below.
+            LOG.info("IVM baseline partitions were removed by partition sync, 
mv={}, taskId={}",
+                    mtmv.getName(), getTaskId());
+        } else {
+            baselinePartitions.sort(String::compareTo);
+            this.needRefreshPartitions = baselinePartitions;
+            this.refreshMode = generateRefreshMode(baselinePartitions);
+            executePartitionBasedRefresh(context, RefreshMode.PARTITIONS, ctx);
         }
-        executePartitionBasedRefresh(context, RefreshMode.PARTITIONS, ctx);
-        return true;
+        mtmv.releaseIvmBaselineRebuild(mtmvSchemaChangeVersion);
     }
 
     private void validateIvmBaselineBeforePartitionSync(RefreshRequest 
request) throws JobException {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
index dd2806fd88b..19266feb1f9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
@@ -86,16 +86,16 @@ public class MTMVRelationManager implements MTMVHookService 
{
     }
 
     public void markIvmBaselineRebuild(BaseTableInfo baseTableInfo, String 
reason) {
-        markIvmBaselineRebuild(baseTableInfo, Collections.emptyMap(), reason);
+        markIvmBaselineRebuild(baseTableInfo, true, Collections.emptyMap(), 
reason);
     }
 
     public void markIvmBaselineRebuildForPartitionChange(BaseTableInfo 
baseTableInfo,
             Map<String, Long> changedPartitions, String reason) {
         Preconditions.checkArgument(!changedPartitions.isEmpty(), "changed 
partitions can not be empty");
-        markIvmBaselineRebuild(baseTableInfo, changedPartitions, reason);
+        markIvmBaselineRebuild(baseTableInfo, false, changedPartitions, 
reason);
     }
 
-    private void markIvmBaselineRebuild(BaseTableInfo baseTableInfo,
+    private void markIvmBaselineRebuild(BaseTableInfo baseTableInfo, boolean 
allPartitionsChanged,
             Map<String, Long> changedPartitions, String reason) {
         TableNameInfo baseTableName = new 
TableNameInfo(baseTableInfo.getCtlName(),
                 baseTableInfo.getDbName(), baseTableInfo.getTableName());
@@ -115,7 +115,7 @@ public class MTMVRelationManager implements MTMVHookService 
{
             if 
(MTMVPartitionUtil.isTableExcluded(mtmv.getExcludedTriggerTables(), 
baseTableName)) {
                 continue;
             }
-            if (changedPartitions.isEmpty()) {
+            if (allPartitionsChanged) {
                 mtmv.invalidateIvmBaseline();
             } else {
                 mtmv.invalidateIvmBaseline(baseTableInfo, changedPartitions);
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
index c01fbe16aa5..0f4d44634ca 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
@@ -816,12 +816,46 @@ public class MTMVTaskTest {
         Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
 
         Deencapsulation.invoke(task, "validateIvmBaselineBeforePartitionSync", 
request);
-        Assertions.assertTrue((Boolean) Deencapsulation.invoke(task, 
"handlePendingIvmBaselineRebuild",
-                Mockito.mock(MTMVRefreshContext.class), request, new 
ConnectContext()));
-        Assertions.assertEquals(MTMVTask.MTMVTaskRefreshMode.NOT_REFRESH,
-                Deencapsulation.getField(task, "refreshMode"));
+        List<Object> attempts = Lists.newArrayList();
+        attempts.addAll(Deencapsulation.invoke(task, "buildAttempts", request, 
false));
+        Assertions.assertEquals("[PARTITIONS, COMPLETE]", attempts.toString());
+
+        Deencapsulation.invoke(task, "handlePendingIvmBaselineRebuild",
+                Mockito.mock(MTMVRefreshContext.class), request, new 
ConnectContext(), attempts);
+
+        // A pending COMPLETE rebuild reshapes the attempt list instead of 
rebuilding inline, so
+        // PARTITIONS FALLBACK rebuilds the whole MV through the COMPLETE 
attempt it keeps.
+        Assertions.assertEquals("[COMPLETE]", attempts.toString());
+        Assertions.assertEquals(IvmFailureReason.BINLOG_BROKEN.name(),
+                Deencapsulation.getField(task, "ivmFallbackReason"));
+        // The barrier is released by the caller once the reshaped attempts 
have run.
+        Mockito.verify(mtmv, 
Mockito.never()).releaseIvmBaselineRebuild(Mockito.anyLong());
+    }
+
+    @Test
+    public void testDroppedBaselinePartitionsReleaseBarrierWithoutRebuild() 
throws Exception {
+        Mockito.when(mtmv.isIvm()).thenReturn(true);
+        IvmInfo ivmInfo = new IvmInfo();
+        ivmInfo.addPendingBaselineRebuildPartitions(Sets.newHashSet(poneName));
+        Mockito.when(mtmv.getIvmInfo()).thenReturn(ivmInfo);
+        // Partition sync already dropped the partition the barrier named, so 
nothing is left to
+        // pre-rebuild and the surviving partitions catch up through the 
attempts themselves.
+        
Mockito.when(mtmv.getPartitionNames()).thenReturn(Sets.newHashSet(ptwoName));
+        MTMVTask task = new MTMVTask(mtmv, relation, MTMVTaskContext.of(
+                MTMVTaskTriggerMode.MANUAL, null, RefreshMode.PARTITIONS, 
true, null));
+        Deencapsulation.setField(task, "mtmvSchemaChangeVersion", 7L);
+        Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
+
+        List<Object> attempts = Lists.newArrayList();
+        attempts.addAll(Deencapsulation.invoke(task, "buildAttempts", request, 
false));
+        Deencapsulation.invoke(task, "handlePendingIvmBaselineRebuild",
+                Mockito.mock(MTMVRefreshContext.class), request, new 
ConnectContext(), attempts);
+
+        Assertions.assertEquals("[PARTITIONS, COMPLETE]", attempts.toString());
+        Assertions.assertNull(Deencapsulation.getField(task, "refreshMode"));
         Assertions.assertEquals(IvmFailureReason.BINLOG_BROKEN.name(),
                 Deencapsulation.getField(task, "ivmFallbackReason"));
+        Mockito.verify(mtmv).releaseIvmBaselineRebuild(7L);
     }
 
     @Test
diff --git 
a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.out
 
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.out
new file mode 100644
index 00000000000..271a5f56d8e
--- /dev/null
+++ 
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.out
@@ -0,0 +1,32 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !baseline_task --
+SUCCESS        NONE    NONE
+
+-- !baseline_base --
+2026-01-10     1       10
+2026-01-10     1       10
+2026-02-10     3       30
+2026-02-10     3       30
+
+-- !baseline_mv --
+2026-01-10     1       10
+2026-01-10     1       10
+2026-02-10     3       30
+2026-02-10     3       30
+
+-- !strict_task --
+FAILED NOT_REFRESH     BINLOG_BROKEN
+
+-- !fallback_task --
+SUCCESS        PARTIAL BINLOG_BROKEN
+
+-- !fallback_base --
+2026-02-10     3       30
+2026-02-10     3       30
+2026-02-15     4       40
+
+-- !fallback_mv --
+2026-02-10     3       30
+2026-02-10     3       30
+2026-02-15     4       40
+
diff --git 
a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.out 
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.out
new file mode 100644
index 00000000000..fab4869556a
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.out
@@ -0,0 +1,45 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !baseline_task --
+SUCCESS        NONE
+
+-- !baseline_base --
+2026-01-10     1       10
+2026-02-10     2       20
+2026-03-10     3       30
+
+-- !baseline_mv --
+2026-01-10     1       10
+2026-02-10     2       20
+2026-03-10     3       30
+
+-- !strict_task --
+FAILED BINLOG_BROKEN
+
+-- !fallback_task --
+SUCCESS        BINLOG_BROKEN
+
+-- !fallback_base --
+2026-02-10     2       20
+2026-02-15     4       40
+2026-03-10     3       30
+
+-- !fallback_mv --
+2026-02-10     2       20
+2026-02-15     4       40
+2026-03-10     3       30
+
+-- !resumed_task --
+SUCCESS        NONE
+
+-- !resumed_base --
+2026-02-10     2       20
+2026-02-15     4       40
+2026-03-10     3       30
+2026-03-15     5       50
+
+-- !resumed_mv --
+2026-02-10     2       20
+2026-02-15     4       40
+2026-03-10     3       30
+2026-03-15     5       50
+
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.groovy
 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.groovy
new file mode 100644
index 00000000000..8f849196d47
--- /dev/null
+++ 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.groovy
@@ -0,0 +1,123 @@
+// 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
+
+/**
+ * Same baseline-rebuild pre-step as test_ivm_partition_drop_live_delta, but 
on the branch where the
+ * affected MV partition SURVIVES: TRUNCATE keeps the partition range, so 
partition sync leaves the
+ * MV partition in place and the pre-step really has something to rebuild.
+ *
+ * <p>Two things are pinned here. The refreshed partition is picked up again 
by the IVM attempt that
+ * follows, which may only apply the remaining delta -- on a duplicate-key MV 
a double apply shows up
+ * as extra copies of the same row, not as a wrong value. And the row written 
to the surviving
+ * partition after the truncate must still be consumed. Both are checked by 
comparing whole result
+ * sets, so row multiplicities are part of the expectation.
+ */
+suite("test_ivm_partition_baseline_rebuild_dup_keys") {
+    def tableName = "ivm_part_dup_t"
+    def mvName = "ivm_part_dup_mv"
+
+    def waitForNewTask = { 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].TaskId.toString()
+    }
+
+    // Unset RefreshMode / IvmFallbackReason come back as the literal 
two-character string "\N",
+    // which does not survive the .out round trip, so fold the unset value 
into a printable token.
+    def taskQuery = { String taskId ->
+        """
+            SELECT Status,
+                   CASE WHEN RefreshMode IN ('COMPLETE', 'PARTIAL', 
'NOT_REFRESH')
+                        THEN RefreshMode ELSE 'NONE' END,
+                   CASE WHEN IvmFallbackReason = 'BINLOG_BROKEN'
+                        THEN IvmFallbackReason ELSE 'NONE' END
+            FROM tasks('type'='mv')
+            WHERE TaskId = '${taskId}'
+        """
+    }
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+    sql """DROP TABLE IF EXISTS ${tableName}"""
+    sql """
+        CREATE TABLE ${tableName} (
+            dt DATE NOT NULL,
+            id INT NOT NULL,
+            v INT
+        )
+        DUPLICATE KEY(dt, id)
+        PARTITION BY RANGE(dt) ()
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1",
+            "binlog.enable" = "true",
+            "binlog.format" = "ROW"
+        )
+    """
+    sql """ALTER TABLE ${tableName} ADD PARTITION p202601 VALUES 
[('2026-01-01'), ('2026-02-01'))"""
+    sql """ALTER TABLE ${tableName} ADD PARTITION p202602 VALUES 
[('2026-02-01'), ('2026-03-01'))"""
+    // Repeated identical rows: a double-applied delta grows the multiplicity 
instead of hiding in a
+    // unique key.
+    sql """INSERT INTO ${tableName} VALUES
+            ('2026-01-10', 1, 10), ('2026-01-10', 1, 10),
+            ('2026-02-10', 3, 30), ('2026-02-10', 3, 30)"""
+
+    sql """
+        CREATE MATERIALIZED VIEW ${mvName}
+        BUILD DEFERRED REFRESH INCREMENTAL FALLBACK ON MANUAL
+        PARTITION BY(dt)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+        AS SELECT dt, id, v FROM ${tableName}
+    """
+
+    sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+    def taskId = waitForNewTask(null)
+    qt_baseline_task taskQuery(taskId)
+    order_qt_baseline_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt, 
id, v"""
+    order_qt_baseline_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id, 
v"""
+
+    // TRUNCATE replaces the partition, so the MV partition of that range 
stays alive and the
+    // baseline pre-step has a real partition to rebuild.
+    sql """TRUNCATE TABLE ${tableName} PARTITION(p202601)"""
+    sql """INSERT INTO ${tableName} VALUES ('2026-02-15', 4, 40)"""
+
+    sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+    taskId = waitForNewTask(taskId)
+    qt_strict_task taskQuery(taskId)
+
+    sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL FALLBACK"""
+    taskId = waitForNewTask(taskId)
+    qt_fallback_task taskQuery(taskId)
+    order_qt_fallback_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt, 
id, v"""
+    order_qt_fallback_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id, 
v"""
+}
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.groovy
new file mode 100644
index 00000000000..bc9f182e306
--- /dev/null
+++ 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.groovy
@@ -0,0 +1,134 @@
+// 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
+
+/**
+ * Dropping a base-table partition invalidates the IVM baseline, because the 
rows disappear through
+ * metadata rather than through row binlog entries. The MV partition built 
from that base partition
+ * is then removed by partition sync, which is exactly what the baseline 
barrier recorded.
+ *
+ * <p>The refresh must still consume the delta that accumulated on the 
*surviving* partitions: it
+ * may not report SUCCESS while leaving those partitions stale. This case 
inserts a row into a
+ * surviving partition after the drop, so an EMPTY baseline-rebuild 
intersection cannot be mistaken
+ * for "nothing to do".
+ *
+ * <p>Partitions are managed by hand (no dynamic partition scheduler) and 
every dt is a literal, so
+ * the case is fully deterministic.
+ */
+suite("test_ivm_partition_drop_live_delta") {
+    def tableName = "ivm_part_drop_t"
+    def mvName = "ivm_part_drop_mv"
+
+    def waitForNewTask = { 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].TaskId.toString()
+    }
+
+    // An unset IvmFallbackReason comes back as the literal two-character 
string "\N", which does not
+    // survive the .out round trip, so fold the unset value into a printable 
token.
+    def taskQuery = { String taskId ->
+        """
+            SELECT Status,
+                   CASE WHEN IvmFallbackReason = 'BINLOG_BROKEN' THEN 
IvmFallbackReason ELSE 'NONE' END
+            FROM tasks('type'='mv')
+            WHERE TaskId = '${taskId}'
+        """
+    }
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+    sql """DROP TABLE IF EXISTS ${tableName}"""
+    sql """
+        CREATE TABLE ${tableName} (
+            dt DATE NOT NULL,
+            id INT NOT NULL,
+            v INT
+        )
+        UNIQUE KEY(dt, id)
+        PARTITION BY RANGE(dt) ()
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1",
+            "enable_unique_key_merge_on_write" = "true",
+            "binlog.enable" = "true",
+            "binlog.format" = "ROW",
+            "binlog.need_historical_value" = "true"
+        )
+    """
+    sql """ALTER TABLE ${tableName} ADD PARTITION p202601 VALUES 
[('2026-01-01'), ('2026-02-01'))"""
+    sql """ALTER TABLE ${tableName} ADD PARTITION p202602 VALUES 
[('2026-02-01'), ('2026-03-01'))"""
+    sql """ALTER TABLE ${tableName} ADD PARTITION p202603 VALUES 
[('2026-03-01'), ('2026-04-01'))"""
+    sql """ALTER TABLE ${tableName} ADD PARTITION p202604 VALUES 
[('2026-04-01'), ('2026-05-01'))"""
+    sql """INSERT INTO ${tableName} VALUES
+            ('2026-01-10', 1, 10), ('2026-02-10', 2, 20), ('2026-03-10', 3, 
30)"""
+
+    sql """
+        CREATE MATERIALIZED VIEW ${mvName}
+        BUILD DEFERRED REFRESH INCREMENTAL FALLBACK ON MANUAL
+        KEY(dt, id)
+        PARTITION BY(dt)
+        DISTRIBUTED BY HASH(id) BUCKETS 1
+        PROPERTIES ("replication_num" = "1")
+        AS SELECT dt, id, v FROM ${tableName}
+    """
+
+    sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+    def taskId = waitForNewTask(null)
+    qt_baseline_task taskQuery(taskId)
+    order_qt_baseline_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt, 
id"""
+    order_qt_baseline_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id"""
+
+    sql """ALTER TABLE ${tableName} DROP PARTITION p202601"""
+    sql """INSERT INTO ${tableName} VALUES ('2026-02-15', 4, 40)"""
+
+    // A strict incremental refresh must refuse to run against a broken 
baseline.
+    sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+    taskId = waitForNewTask(taskId)
+    qt_strict_task taskQuery(taskId)
+
+    // The fallback reports SUCCESS, so the MV has to match the base table 
afterwards: the expired
+    // partition is gone AND the row written to the surviving partition has 
been consumed. An MV
+    // that is missing that row means the refresh silently skipped the 
surviving partitions' delta.
+    sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL FALLBACK"""
+    taskId = waitForNewTask(taskId)
+    qt_fallback_task taskQuery(taskId)
+    order_qt_fallback_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt, 
id"""
+    order_qt_fallback_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id"""
+
+    // A following strict incremental refresh must be able to continue from 
the repaired baseline.
+    sql """INSERT INTO ${tableName} VALUES ('2026-03-15', 5, 50)"""
+    sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+    taskId = waitForNewTask(taskId)
+    qt_resumed_task taskQuery(taskId)
+    order_qt_resumed_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt, 
id"""
+    order_qt_resumed_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id"""
+}


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

Reply via email to