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 7bd89a0795c [fix](ivm) Stop the incremental delta from reading 
partitions the MV dropped (#67814)
7bd89a0795c is described below

commit 7bd89a0795ce7aa43d715fa57a591d0a7f4f8035
Author: yujun <[email protected]>
AuthorDate: Fri Sep 11 17:22:28 2026 +0800

    [fix](ivm) Stop the incremental delta from reading partitions the MV 
dropped (#67814)
    
    `partition_sync_limit` keeps only a recent slice of each base table's
    partitions, so an MV can be missing a partition its base table still
    has. The incremental delta read that base partition anyway, through the
    delta scan and through the join-opposite snapshot. A change to a
    non-partitioned dimension therefore produced delta rows for dates the MV
    has no partition for, and the insert failed with `no partition for this
    tuple`.
    
    Re-syncing cannot bring an expired partition back, so the retry loop in
    `executeIvmAttempt` could never recover: the task failed after
    exhausting its attempts, and because the write is atomic, the partitions
    the MV does keep were left unrepaired as well. A strict `REFRESH ...
    INCREMENTAL`, and a scheduled refresh of an MV declared without
    `FALLBACK`, fail outright.
    
    ### What changed
    
    The incremental delta now reads only the base partitions the MV's
    partition definition keeps.
    
    - `MTMVPartitionUtil.generateRelatedBasePartitionIds` returns that
    partition set per base table partitioned by the MV's partition column,
    and an empty value when `partition_sync_limit` is not set, which is the
    only property that can leave the MV without a base partition.
    - `IvmIncrRefreshManager` passes it to `IvmRewriteContext.incremental`,
    and `IvmDeltaRewriter` applies it as an upper bound on the partitions
    each base table may be read from, intersected with
    `ivm_partition_window_limit` when that is set too.
    - The bound is the partition set the MV is aligned to rather than the
    partitions it already has, so a base partition whose MV partition has
    not been added yet stays readable: the refresh still reports the missing
    partition and recovers it by syncing, which is what keeps a newly added
    base partition working.
    - A base table absent from the set keeps its full read, since the MV
    partition column does not come from it and limiting it would change join
    results without narrowing the target MV partitions. A set covering every
    partition of its table leaves the plan untouched, so an MV that mirrors
    all of its base partitions is unaffected.
    - Only olap tables are reachable: the delta reads them through their
    stream and restricts a scan by partition id, which a connector table has
    no equivalent of.
    
    The COMPLETE paths are unchanged.
    
    The PR also removes the `nonConcurrent` group from seven `mtmv_p0/ivm`
    suites that use neither debug points nor global variables or config, so
    they run in the parallel pool again.
    
    Trace issue: https://github.com/apache/doris/issues/65418
---
 .../org/apache/doris/mtmv/MTMVPartitionUtil.java   |  57 ++++++++
 .../org/apache/doris/mtmv/MTMVPropertyUtil.java    |  13 ++
 .../apache/doris/mtmv/ivm/IvmDeltaRewriter.java    |  64 ++++++++-
 .../doris/mtmv/ivm/IvmIncrRefreshManager.java      |  16 ++-
 .../apache/doris/mtmv/ivm/IvmRewriteContext.java   |  51 +++++--
 .../apache/doris/mtmv/MTMVPartitionUtilTest.java   |  23 +++
 .../apache/doris/mtmv/MTMVPropertyUtilTest.java    |  13 ++
 .../doris/mtmv/ivm/IvmDeltaRewriterTest.java       |  73 ++++++++++
 .../apache/doris/mtmv/ivm/IvmDeltaTestBase.java    |  21 ++-
 .../mtmv_p0/ivm/test_ivm_partition_sync_limit.out  |  14 ++
 .../test_ivm_partition_sync_limit_with_window.out  |  15 ++
 .../ivm/test_ivm_bitmap_runtime_fallback.groovy    |   2 +-
 .../test_ivm_drop_column_fallback_reason.groovy    |   2 +-
 .../ivm/test_ivm_minmax_runtime_fallback.groovy    |   2 +-
 .../mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy    |   2 +-
 .../ivm/test_ivm_partition_baseline_rebuild.groovy |   2 +-
 .../ivm/test_ivm_partition_sync_limit.groovy       | 159 +++++++++++++++++++++
 ...est_ivm_partition_sync_limit_with_window.groovy | 157 ++++++++++++++++++++
 .../mtmv_p0/ivm/test_ivm_rewrite_projection.groovy |   2 +-
 ...t_ivm_strict_failure_partition_atomicity.groovy |   2 +-
 20 files changed, 670 insertions(+), 20 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
index 7a80da85109..d0bbc341aa5 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
@@ -237,6 +237,63 @@ public class MTMVPartitionUtil {
         return result.getRes();
     }
 
+    /**
+     * Base partitions the MV's partition definition keeps, per base table 
partitioned by the MV's
+     * partition column. This is the partition set the MV is aligned to, so it 
still contains a base
+     * partition whose MV partition the next {@link #alignMvPartition} has yet 
to add, and no longer
+     * contains one the partition properties filter out, such as an expired 
partition_sync_limit.
+     *
+     * <p>Empty when no restriction applies at all: without {@code 
partition_sync_limit} the MV
+     * mirrors every base partition, so there is nothing to restrict. When 
present, the map holds an
+     * entry for every base table the MV partition column comes from, each 
with the partitions that
+     * table may be read from — an empty set meaning that table may not be 
read at all. Keeping the
+     * empty set explicit is what separates "this table is restricted to 
nothing" from "this table
+     * is not one of the MV partition column's sources".
+     *
+     * <p>Read without pinned snapshots, like {@link #alignMvPartition}. A 
base partition that
+     * partition sync adds or drops at the same moment can therefore sit 
outside the returned set
+     * for one refresh; the same refresh either recovers the partition by 
syncing again or leaves
+     * its binlog pending for the next one.
+     *
+     * @return baseTableInfo ==> base partition ids, empty when no restriction 
applies
+     */
+    public static Optional<Map<BaseTableInfo, Set<Long>>> 
generateRelatedBasePartitionIds(MTMV mtmv)
+            throws AnalysisException {
+        MTMVPartitionInfo mvPartitionInfo = mtmv.getMvPartitionInfo();
+        if (mvPartitionInfo == null
+                || mvPartitionInfo.getPartitionType() == 
MTMVPartitionType.SELF_MANAGE
+                || 
!MTMVPropertyUtil.hasPartitionSyncLimit(mtmv.getMvProperties())) {
+            return Optional.empty();
+        }
+        Map<BaseTableInfo, Set<Long>> res = Maps.newHashMap();
+        // Only olap tables are restricted: the delta rewrite reads them 
through their stream and
+        // selects partitions by id, which an external table has no equivalent 
of. A connector
+        // table therefore stays out of the scope and keeps its full read, so 
supporting one as an
+        // IVM base table means giving the scope a partition identity it can 
express, not just
+        // widening these types.
+        for (MTMVRelatedTableIf pctTable : mvPartitionInfo.getPctTables()) {
+            if (pctTable instanceof OlapTable) {
+                res.put(new BaseTableInfo((OlapTable) pctTable), 
Sets.newHashSet());
+            }
+        }
+        Map<PartitionKeyDesc, Map<MTMVRelatedTableIf, Set<String>>> 
relatedDescs = generateRelatedPartitionDescs(
+                mvPartitionInfo, mtmv.getMvProperties(), 
mtmv.getPartitionColumns(), Maps.newHashMap());
+        for (Map<MTMVRelatedTableIf, Set<String>> relatedPartitions : 
relatedDescs.values()) {
+            for (Entry<MTMVRelatedTableIf, Set<String>> entry : 
relatedPartitions.entrySet()) {
+                if (!(entry.getKey() instanceof OlapTable)) {
+                    continue;
+                }
+                OlapTable baseTable = (OlapTable) entry.getKey();
+                Set<Long> partitionIds = res.computeIfAbsent(
+                        new BaseTableInfo(baseTable), key -> 
Sets.newHashSet());
+                for (String partitionName : entry.getValue()) {
+                    
partitionIds.add(baseTable.getPartitionOrAnalysisException(partitionName).getId());
+                }
+            }
+        }
+        return Optional.of(res);
+    }
+
     /**
      * check if table is sync with all baseTables
      *
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
index d1b8a8765b1..ebd4a6f166d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
@@ -218,6 +218,19 @@ public class MTMVPropertyUtil {
                 PropertyAnalyzer.PROPERTIES_IVM_PARTITION_WINDOW_LIMIT));
     }
 
+    /**
+     * True when the MV keeps only a recent slice of each base table's 
partitions. This is the only
+     * property that can leave the MV without a partition the base table still 
has, so it is also
+     * the only case where the incremental delta has to be told which base 
partitions it may read.
+     */
+    public static boolean hasPartitionSyncLimit(Map<String, String> 
mvProperties) {
+        if (mvProperties == null) {
+            return false;
+        }
+        String value = 
mvProperties.get(PropertyAnalyzer.PROPERTIES_PARTITION_SYNC_LIMIT);
+        return !StringUtils.isEmpty(value) && Integer.parseInt(value) > 0;
+    }
+
     /**
      * Look up the window limit configured for a base table, mirroring the
      * excluded_trigger_tables name-matching semantics (empty db/ctl wildcard).
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
index a6894a64149..1d97e87796c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
@@ -26,6 +26,7 @@ import org.apache.doris.catalog.stream.OlapTableStream;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.Pair;
 import org.apache.doris.info.TableNameInfoUtils;
+import org.apache.doris.mtmv.BaseTableInfo;
 import org.apache.doris.mtmv.MTMVPartitionUtil;
 import org.apache.doris.mtmv.MTMVPropertyUtil;
 import org.apache.doris.nereids.trees.expressions.Slot;
@@ -37,6 +38,7 @@ import 
org.apache.doris.nereids.trees.plans.logical.LogicalProject;
 import org.apache.doris.nereids.types.DataType;
 import org.apache.doris.qe.ConnectContext;
 
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -66,7 +68,8 @@ public class IvmDeltaRewriter {
         Pair<Plan, List<LogicalProject<?>>> prefixChain = 
helper.detachAdaptProjectChain(sinkChild);
         Plan rootPlan = prefixChain.first;
         long refreshVersion = refreshContext.getMtmv().getNextRefreshVersion();
-        IvmDeltaRewriteState rewriteState = createDeltaRewriteState(rootPlan, 
refreshContext, refreshVersion);
+        IvmDeltaRewriteState rewriteState = createDeltaRewriteState(rootPlan, 
refreshContext, refreshVersion,
+                rewriteContext.getIncrementalScopePartitionIds());
         Optional<IvmDeltaRewriteResult> deltaResult = rewriteDelta(rootPlan, 
refreshContext, rewriteState);
         if (!deltaResult.isPresent()) {
             return new LogicalEmptyRelation(
@@ -153,7 +156,8 @@ public class IvmDeltaRewriter {
         return IvmDeltaRewriteHelper.INSTANCE.freshPlan(rewritten);
     }
 
-    private IvmDeltaRewriteState createDeltaRewriteState(Plan plan, 
IvmIncrRefreshContext ctx, long refreshVersion) {
+    private IvmDeltaRewriteState createDeltaRewriteState(Plan plan, 
IvmIncrRefreshContext ctx, long refreshVersion,
+            Map<BaseTableInfo, Set<Long>> scopePartitionIds) {
         Map<OlapTable, OlapTableStream> streams = new HashMap<>();
         // Window limits apply to every base table in the plan, including 
excluded
         // trigger tables (their snapshot side is windowed too, so the 
property saves
@@ -181,11 +185,67 @@ public class IvmDeltaRewriter {
                         new TableNameInfo(table.getFullQualifiers()), 
windowLimits));
             }
         }
+        applyScopePartitionIds(windowPartitionIdsByTable, planTables, 
scopePartitionIds);
         return new IvmDeltaRewriteState(streams, 
ctx.isIncludeExhaustedStreams(), refreshVersion,
                 
DataType.fromCatalogType(ctx.getMtmv().getColumn(Column.SEQUENCE_COL).getType()),
                 windowPartitionIdsByTable);
     }
 
+    /**
+     * Limits every base table whose partition column feeds the MV's partition 
column to the base
+     * partitions the MV's partition definition keeps. The delta and the 
join-opposite snapshot
+     * would otherwise also read base partitions the MV does not keep, expired 
by
+     * partition_sync_limit, and then try to write their rows into MV 
partitions that do not exist,
+     * which fails the whole insert with "no partition for this tuple" and 
takes the partitions
+     * that do exist down with it.
+     *
+     * <p>The limit is the partition set the MV is aligned to rather than the 
partitions it already
+     * has, so a base partition whose MV partition has not been added yet 
stays readable: the
+     * refresh still reports the missing partition and recovers it by syncing.
+     *
+     * <p>Tables outside {@code scopePartitionIds} keep their full read: the 
MV partition column
+     * does not come from them, so limiting them would change join results 
without narrowing the
+     * set of MV partitions the delta can target. A scope that covers every 
partition of its table
+     * is left alone as well, so an MV that mirrors all of its base partitions 
keeps the plan it
+     * had before this restriction existed. An entry with no partitions is the 
opposite case and is
+     * applied as it stands: the scan then reads nothing, which is how the 
delta and the snapshot
+     * side both stay out of a table the MV has no partition for.
+     *
+     * <p>The scope is keyed by base table identity, but only olap tables are 
reachable here: the
+     * delta reads them through {@code OlapTableStream} and restricts a scan 
by partition id.
+     */
+    private static void applyScopePartitionIds(Map<OlapTable, List<Long>> 
windowPartitionIdsByTable,
+            Set<OlapTable> planTables, Map<BaseTableInfo, Set<Long>> 
scopePartitionIds) {
+        if (scopePartitionIds.isEmpty()) {
+            return;
+        }
+        for (OlapTable table : planTables) {
+            Set<Long> tableScope = scopePartitionIds.get(new 
BaseTableInfo(table));
+            if (tableScope == null || 
tableScope.containsAll(table.getPartitionIds())) {
+                continue;
+            }
+            List<Long> windowPartitionIds = 
windowPartitionIdsByTable.get(table);
+            List<Long> readablePartitionIds;
+            if (windowPartitionIds == null) {
+                readablePartitionIds = new ArrayList<>(tableScope);
+            } else {
+                // Both limits apply, so only their intersection is readable. 
One keeps the
+                // partitions above a time cutoff and the other the last N by 
partition value, so
+                // each is a suffix of the same value order and the two cannot 
be disjoint.
+                readablePartitionIds = new ArrayList<>();
+                for (Long partitionId : windowPartitionIds) {
+                    if (tableScope.contains(partitionId)) {
+                        readablePartitionIds.add(partitionId);
+                    }
+                }
+            }
+            // Sorted like every other partition selection handed to a scan, 
so the plan shape does
+            // not depend on the order the two limits were combined in.
+            readablePartitionIds.sort(Long::compareTo);
+            windowPartitionIdsByTable.put(table, readablePartitionIds);
+        }
+    }
+
     boolean isExcludedTriggerTable(LogicalOlapScan scan, Set<TableNameInfo> 
excludedTriggerTables) {
         if (excludedTriggerTables == null || excludedTriggerTables.isEmpty()) {
             return false;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManager.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManager.java
index 886d6ee6c23..4559fd54937 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManager.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManager.java
@@ -22,6 +22,8 @@ import org.apache.doris.common.util.DebugPointUtil;
 import org.apache.doris.common.util.DebugUtil;
 import org.apache.doris.common.util.Util;
 import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.mtmv.BaseTableInfo;
+import org.apache.doris.mtmv.MTMVPartitionUtil;
 import org.apache.doris.mtmv.MTMVPlanUtil;
 import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.analyzer.UnboundTableSink;
@@ -39,9 +41,12 @@ import com.google.common.collect.ImmutableList;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
+import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 
 /**
  * Minimal orchestration entry point for incremental refresh.
@@ -92,7 +97,16 @@ public class IvmIncrRefreshManager {
         MTMV mtmv = context.getMtmv();
         StatementContext statementContext = new StatementContext(
                 context.getConnectContext(), new 
OriginStatement(mtmv.getQuerySql(), 0));
-        
statementContext.setIvmRewriteContext(Optional.of(IvmRewriteContext.incremental(mtmv)));
+        // The delta may only read the base partitions the MV's partition 
definition keeps. A base
+        // partition outside that set, expired by partition_sync_limit, would 
otherwise still be
+        // read through the delta and the join-opposite snapshot, and its rows 
would have no MV
+        // partition to land in. A base partition inside the set stays 
readable even when its MV
+        // partition is not there yet, so that the refresh still reports the 
missing partition and
+        // recovers it by syncing.
+        Map<BaseTableInfo, Set<Long>> scopePartitionIds =
+                
MTMVPartitionUtil.generateRelatedBasePartitionIds(mtmv).orElse(Collections.emptyMap());
+        statementContext.setIvmRewriteContext(
+                Optional.of(IvmRewriteContext.incremental(mtmv, 
scopePartitionIds)));
         // Excluded trigger tables do not produce delta and must not be 
validated for
         // binlog / key-type support during the incremental analyze.
         
statementContext.setExcludedTriggerTables(mtmv.getExcludedTriggerTables());
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRewriteContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRewriteContext.java
index 414a1df3d51..a6fefda42c9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRewriteContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRewriteContext.java
@@ -76,6 +76,11 @@ public class IvmRewriteContext {
     private final Optional<IvmDryRunLimit> dryRunLimit;
     private final Map<BaseTableInfo, Set<Long>> fullRefreshResetPartitionIds;
     private final Optional<StreamReadMode> fullRefreshNonPctReadMode;
+    // Base partitions the incremental delta may read, per base table. A table 
that is absent is
+    // read in full, so an empty map keeps the unrestricted behaviour. Unlike
+    // fullRefreshResetPartitionIds, which names the partitions a COMPLETE 
refresh must reset,
+    // every entry here is an upper bound on what the delta is allowed to read.
+    private final Map<BaseTableInfo, Set<Long>> incrementalScopePartitionIds;
     // Set by MTMVPlanUtil before normalization: true means the MV unique keys 
include identity key columns.
     // Null when the rewrite context is created outside the analyzeQuery flow.
     private Boolean useFullKeys;
@@ -83,7 +88,8 @@ public class IvmRewriteContext {
     private IvmRewriteContext(Mode mode, MTMV mtmv, String createMtmvName, 
boolean includeExhaustedStreams,
             ExecutionKind executionKind, Optional<IvmDryRunLimit> dryRunLimit,
             Map<BaseTableInfo, Set<Long>> fullRefreshResetPartitionIds,
-            Optional<StreamReadMode> fullRefreshNonPctReadMode) {
+            Optional<StreamReadMode> fullRefreshNonPctReadMode,
+            Map<BaseTableInfo, Set<Long>> incrementalScopePartitionIds) {
         this.mode = Objects.requireNonNull(mode, "mode can not be null");
         this.mtmv = mode == Mode.CREATE ? mtmv : Objects.requireNonNull(mtmv, 
"mtmv can not be null");
         this.createMtmvName = createMtmvName;
@@ -97,45 +103,66 @@ public class IvmRewriteContext {
         this.fullRefreshResetPartitionIds = 
Collections.unmodifiableMap(resetPartitionIds);
         this.fullRefreshNonPctReadMode = Objects.requireNonNull(
                 fullRefreshNonPctReadMode, "fullRefreshNonPctReadMode can not 
be null");
+        Map<BaseTableInfo, Set<Long>> scopePartitionIds = new HashMap<>();
+        Objects.requireNonNull(incrementalScopePartitionIds, 
"incrementalScopePartitionIds can not be null")
+                .forEach((baseTableInfo, partitionIds) -> 
scopePartitionIds.put(baseTableInfo,
+                        Collections.unmodifiableSet(new 
HashSet<>(partitionIds))));
+        this.incrementalScopePartitionIds = 
Collections.unmodifiableMap(scopePartitionIds);
     }
 
     public static IvmRewriteContext create(String mtmvName) {
         return new IvmRewriteContext(Mode.CREATE, null,
                 Objects.requireNonNull(mtmvName, "mtmvName can not be null"), 
false,
-                ExecutionKind.EXECUTE, Optional.empty(), 
Collections.emptyMap(), Optional.empty());
+                ExecutionKind.EXECUTE, Optional.empty(), 
Collections.emptyMap(), Optional.empty(),
+                Collections.emptyMap());
     }
 
     public static IvmRewriteContext normalize(MTMV mtmv) {
         return new IvmRewriteContext(Mode.NORMALIZE, 
Objects.requireNonNull(mtmv, "mtmv can not be null"),
-                null, false, ExecutionKind.EXECUTE, Optional.empty(), 
Collections.emptyMap(), Optional.empty());
+                null, false, ExecutionKind.EXECUTE, Optional.empty(), 
Collections.emptyMap(), Optional.empty(),
+                Collections.emptyMap());
     }
 
     public static IvmRewriteContext incremental(MTMV mtmv) {
+        return incremental(mtmv, Collections.emptyMap());
+    }
+
+    /**
+     * Incremental refresh whose delta may only read the given base 
partitions. A base partition
+     * outside the scope is read neither through its stream nor through the 
join-opposite
+     * snapshot, so a base partition the MV no longer mirrors cannot produce 
delta rows that the
+     * MV has no target partition for.
+     */
+    public static IvmRewriteContext incremental(MTMV mtmv, Map<BaseTableInfo, 
Set<Long>> scopePartitionIds) {
         return new IvmRewriteContext(Mode.INCREMENTAL, 
Objects.requireNonNull(mtmv, "mtmv can not be null"),
-                null, false, ExecutionKind.EXECUTE, Optional.empty(), 
Collections.emptyMap(), Optional.empty());
+                null, false, ExecutionKind.EXECUTE, Optional.empty(), 
Collections.emptyMap(), Optional.empty(),
+                scopePartitionIds);
     }
 
     /** EXPLAIN REFRESH INCREMENTAL [ALL]: only a plan is produced. */
     public static IvmRewriteContext incrementalExplain(MTMV mtmv, boolean 
includeExhaustedStreams) {
         return new IvmRewriteContext(Mode.INCREMENTAL, 
Objects.requireNonNull(mtmv, "mtmv can not be null"),
                 null, includeExhaustedStreams, ExecutionKind.EXPLAIN,
-                Optional.empty(), Collections.emptyMap(), Optional.empty());
+                Optional.empty(), Collections.emptyMap(), Optional.empty(), 
Collections.emptyMap());
     }
 
     public static IvmRewriteContext incrementalDryRun(MTMV mtmv, 
Optional<IvmDryRunLimit> dryRunLimit) {
         return new IvmRewriteContext(Mode.INCREMENTAL, mtmv, null, false,
-                ExecutionKind.DRY_RUN, dryRunLimit, Collections.emptyMap(), 
Optional.empty());
+                ExecutionKind.DRY_RUN, dryRunLimit, Collections.emptyMap(), 
Optional.empty(),
+                Collections.emptyMap());
     }
 
     /** EXPLAIN REFRESH COMPLETE: only a plan is produced. */
     public static IvmRewriteContext fullExplain(MTMV mtmv) {
         return new IvmRewriteContext(Mode.FULL, Objects.requireNonNull(mtmv, 
"mtmv can not be null"),
-                null, false, ExecutionKind.EXPLAIN, Optional.empty(), 
Collections.emptyMap(), Optional.empty());
+                null, false, ExecutionKind.EXPLAIN, Optional.empty(), 
Collections.emptyMap(), Optional.empty(),
+                Collections.emptyMap());
     }
 
     public static IvmRewriteContext full(MTMV mtmv) {
         return new IvmRewriteContext(Mode.FULL, Objects.requireNonNull(mtmv, 
"mtmv can not be null"),
-                null, false, ExecutionKind.EXECUTE, Optional.empty(), 
Collections.emptyMap(), Optional.empty());
+                null, false, ExecutionKind.EXECUTE, Optional.empty(), 
Collections.emptyMap(), Optional.empty(),
+                Collections.emptyMap());
     }
 
     public static IvmRewriteContext full(MTMV mtmv,
@@ -143,7 +170,8 @@ public class IvmRewriteContext {
             StreamReadMode nonPctReadMode) {
         return new IvmRewriteContext(Mode.FULL, mtmv, null, false, 
ExecutionKind.EXECUTE, Optional.empty(),
                 resetPartitionIds,
-                Optional.of(Objects.requireNonNull(nonPctReadMode, 
"nonPctReadMode can not be null")));
+                Optional.of(Objects.requireNonNull(nonPctReadMode, 
"nonPctReadMode can not be null")),
+                Collections.emptyMap());
     }
 
     public Mode getMode() {
@@ -189,6 +217,11 @@ public class IvmRewriteContext {
         return 
Optional.ofNullable(fullRefreshResetPartitionIds.get(baseTableInfo)).map(HashSet::new);
     }
 
+    /** Empty when the incremental delta is not limited to a partition subset. 
*/
+    public Map<BaseTableInfo, Set<Long>> getIncrementalScopePartitionIds() {
+        return incrementalScopePartitionIds;
+    }
+
     public Optional<StreamReadMode> getFullRefreshNonPctReadMode() {
         return fullRefreshNonPctReadMode;
     }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
index dad6c3b5b1c..a96caf01d94 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
@@ -312,6 +312,29 @@ public class MTMVPartitionUtilTest {
         Assertions.assertFalse(MTMVPartitionUtil.isTableNamelike(new 
TableNameInfo("ctl1"), tableNameToCheck));
     }
 
+    @Test
+    public void testGenerateRelatedBasePartitionIdsWithoutSyncLimit() throws 
AnalysisException {
+        // Without partition_sync_limit the MV mirrors every base partition, 
so the incremental
+        // delta has nothing to restrict and must be left alone.
+        
Mockito.when(mtmvPartitionInfo.getPartitionType()).thenReturn(MTMVPartitionType.FOLLOW_BASE_TABLE);
+        Mockito.when(mtmv.getMvProperties()).thenReturn(Maps.newHashMap());
+        
Assertions.assertFalse(MTMVPartitionUtil.generateRelatedBasePartitionIds(mtmv).isPresent());
+    }
+
+    @Test
+    public void testGenerateRelatedBasePartitionIdsOnSelfManageMv() throws 
AnalysisException {
+        // setUp leaves the mocked MV on SELF_MANAGE: it decides its own 
partitions, so there is no
+        // base partition mapping to restrict the delta to.
+        
Assertions.assertFalse(MTMVPartitionUtil.generateRelatedBasePartitionIds(mtmv).isPresent());
+    }
+
+    @Test
+    public void testGenerateRelatedBasePartitionIdsWithoutMvPartitionInfo() 
throws AnalysisException {
+        MTMV mvWithoutPartitionInfo = Mockito.mock(MTMV.class);
+        Assertions.assertFalse(
+                
MTMVPartitionUtil.generateRelatedBasePartitionIds(mvWithoutPartitionInfo).isPresent());
+    }
+
     @Test
     public void testGetBaseVersionsUsesMappedPartitions() throws 
AnalysisException {
         Map<String, Map<MTMVRelatedTableIf, Set<String>>> partitionMappings = 
Maps.newHashMap();
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPropertyUtilTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPropertyUtilTest.java
index 4593e498e1d..2fbc5a06359 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPropertyUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPropertyUtilTest.java
@@ -106,6 +106,19 @@ class MTMVPropertyUtilTest {
         
Assertions.assertTrue(MTMVPropertyUtil.getIvmPartitionWindowLimit(null).isEmpty());
     }
 
+    @Test
+    void testHasPartitionSyncLimit() {
+        String key = PropertyAnalyzer.PROPERTIES_PARTITION_SYNC_LIMIT;
+        Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(null));
+        
Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of()));
+        
Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
 "")));
+        // A limit that keeps nothing is not a limit: no base partition is 
filtered out by it.
+        
Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
 "0")));
+        
Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
 "-1")));
+        
Assertions.assertTrue(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
 "1")));
+        
Assertions.assertTrue(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
 "2")));
+    }
+
     @Test
     void testGetPartitionWindowLimitNameMatching() {
         Map<TableNameInfo, Integer> windowLimits =
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriterTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriterTest.java
index ac8b48a4d44..e3f4a074efd 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriterTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriterTest.java
@@ -19,7 +19,10 @@ package org.apache.doris.mtmv.ivm;
 
 import org.apache.doris.catalog.Column;
 import org.apache.doris.catalog.MTMV;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.Partition;
 import org.apache.doris.common.util.PropertyAnalyzer;
+import org.apache.doris.mtmv.BaseTableInfo;
 import org.apache.doris.nereids.analyzer.UnboundTableSink;
 import org.apache.doris.nereids.jobs.JobContext;
 import org.apache.doris.nereids.rules.analysis.CheckAfterRewrite;
@@ -50,8 +53,12 @@ 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;
+import java.util.stream.Collectors;
 
 class IvmDeltaRewriterTest extends IvmDeltaTestBase {
 
@@ -112,6 +119,25 @@ class IvmDeltaRewriterTest extends IvmDeltaTestBase {
                 new JoinReorderContext());
     }
 
+    private Plan generateMergedDeltaWithScope(Plan plan, Map<BaseTableInfo, 
Set<Long>> scopePartitionIds) {
+        ConnectContext connectContext = newConnectContext();
+        JobContext jobContext = newJobContextForRoot(plan, connectContext);
+        Plan normalizedPlan = new IvmNormalizeMTMV().rewriteRoot(plan, 
jobContext);
+        IvmRewriteResult rewriteResult = 
jobContext.getCascadesContext().getIvmRewriteResult().get();
+        MTMV mtmv = buildMtmvFromPlan(normalizedPlan.getOutput());
+        return new IvmDeltaRewriter().generateIncrRefreshPlan(normalizedPlan, 
rewriteResult,
+                IvmRewriteContext.incremental(mtmv, scopePartitionIds), 
connectContext);
+    }
+
+    /** The partition selection of every olap scan in the plan, in plan order. 
*/
+    private List<List<Long>> collectedPartitionSelections(Plan plan) {
+        List<List<Long>> selections = new ArrayList<>();
+        for (LogicalOlapScan scan : collectScans(plan)) {
+            selections.add(new ArrayList<>(scan.getSelectedPartitionIds()));
+        }
+        return selections;
+    }
+
     private List<LogicalOlapScan> collectScans(Plan plan) {
         return plan.collectToList(n -> n instanceof LogicalOlapScan);
     }
@@ -297,6 +323,53 @@ class IvmDeltaRewriterTest extends IvmDeltaTestBase {
         
Assertions.assertTrue(IvmDeltaRewriteHelper.INSTANCE.isIncrementalDeltaScan(collectScans(rewritten).get(0)));
     }
 
+    @Test
+    void testIncrementalScopeRestrictsDeltaToScopePartitions() {
+        LogicalOlapScan scan = buildScanForTableWithTwoPartitions(120, 
"scope_subset");
+        OlapTable table = scan.getTable();
+        bumpBaseTableTso(table, 20);
+        setStreamOffset(table, getRegisteredStream(table, 1L), 10);
+        long keptPartitionId = table.getPartition("p2").getId();
+
+        Plan rewritten = generateMergedDeltaWithScope(scan,
+                ImmutableMap.of(new BaseTableInfo(table), 
Sets.newHashSet(keptPartitionId)));
+
+        List<LogicalOlapScan> scans = collectScans(rewritten);
+        Assertions.assertFalse(scans.isEmpty());
+        for (LogicalOlapScan rewrittenScan : scans) {
+            Assertions.assertEquals(ImmutableList.of(keptPartitionId),
+                    rewrittenScan.getSelectedPartitionIds());
+        }
+    }
+
+    @Test
+    void testIncrementalScopeCoveringAllPartitionsLeavesScanUnchanged() {
+        LogicalOlapScan scan = buildScanForTableWithTwoPartitions(121, 
"scope_all");
+        OlapTable table = scan.getTable();
+        bumpBaseTableTso(table, 20);
+        setStreamOffset(table, getRegisteredStream(table, 1L), 10);
+
+        Plan withoutScope = generateMergedDelta(scan, false);
+        Plan withScope = generateMergedDeltaWithScope(scan, 
ImmutableMap.of(new BaseTableInfo(table),
+                
table.getPartitions().stream().map(Partition::getId).collect(Collectors.toSet())));
+
+        Assertions.assertEquals(collectedPartitionSelections(withoutScope),
+                collectedPartitionSelections(withScope));
+    }
+
+    @Test
+    void testIncrementalScopeWithoutPartitionsProducesEmptyRelation() {
+        LogicalOlapScan scan = buildScanForTableWithTwoPartitions(122, 
"scope_empty");
+        OlapTable table = scan.getTable();
+        bumpBaseTableTso(table, 20);
+        setStreamOffset(table, getRegisteredStream(table, 1L), 10);
+
+        Plan rewritten = generateMergedDeltaWithScope(scan,
+                ImmutableMap.of(new BaseTableInfo(table), Sets.newHashSet()));
+
+        Assertions.assertInstanceOf(LogicalEmptyRelation.class, rewritten);
+    }
+
     @Test
     void testRecursiveJoinDeltaMergesBothScanDeltas() {
         LogicalOlapScan left = buildScanForTable(301, "recursive_left");
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
index 6e6e5fd80a6..32c78df1adc 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
@@ -480,7 +480,11 @@ abstract class IvmDeltaTestBase {
             return;
         }
         long partitionId = table.getId() * 100 + 1;
-        Partition partition = new Partition(partitionId, "p1",
+        addTestPartition(table, "p1", partitionId);
+    }
+
+    private void addTestPartition(OlapTable table, String name, long 
partitionId) {
+        Partition partition = new Partition(partitionId, name,
                 new MaterializedIndex(table.getBaseIndexId(), 
MaterializedIndex.IndexState.NORMAL),
                 new RandomDistributionInfo(1));
         partition.setVisibleVersionAndTime(Partition.PARTITION_INIT_VERSION + 
1,
@@ -488,4 +492,19 @@ abstract class IvmDeltaTestBase {
         partition.setNextVersion(Partition.PARTITION_INIT_VERSION + 2);
         table.addPartition(partition);
     }
+
+    /**
+     * Builds a scan for a table with two partitions, p1 and p2, so that a 
partition subset is
+     * available to restrict a scan to.
+     */
+    protected LogicalOlapScan buildScanForTableWithTwoPartitions(long tableId, 
String tableName) {
+        OlapTable table = PlanConstructor.newOlapTable(tableId, tableName, 0);
+        addTestPartition(table);
+        addTestPartition(table, "p2", tableId * 100 + 2);
+        enableRowBinlog(table);
+        table.setQualifiedDbName("test_db");
+        registerTestStreams(table);
+        return new LogicalOlapScan(PlanConstructor.getNextRelationId(), table,
+                ImmutableList.of("test_db"));
+    }
 }
diff --git a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit.out 
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit.out
new file mode 100644
index 00000000000..ba46dd58702
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit.out
@@ -0,0 +1,14 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !complete_task --
+SUCCESS        COMPLETE        NONE
+
+-- !complete_mv --
+2      20      \N
+3      30      known
+
+-- !incremental_task --
+SUCCESS        NONE    NONE
+
+-- !incremental_mv --
+2      20      late-arriving
+3      30      known
diff --git 
a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.out
 
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.out
new file mode 100644
index 00000000000..f6eb8f2e733
--- /dev/null
+++ 
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.out
@@ -0,0 +1,15 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !complete_task --
+SUCCESS        COMPLETE        NONE
+
+-- !complete_mv --
+2      20      \N
+3      30      \N
+
+-- !incremental_task --
+SUCCESS        NONE    NONE
+
+-- !incremental_mv --
+2      20      \N
+3      30      late-arriving
+
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_bitmap_runtime_fallback.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_bitmap_runtime_fallback.groovy
index fcbe8689e88..c49540bdc44 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_bitmap_runtime_fallback.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_bitmap_runtime_fallback.groovy
@@ -18,7 +18,7 @@
 import org.awaitility.Awaitility
 import static java.util.concurrent.TimeUnit.SECONDS
 
-suite("test_ivm_bitmap_runtime_fallback", "nonConcurrent") {
+suite("test_ivm_bitmap_runtime_fallback") {
     sql """drop materialized view if exists ivm_bm_fb_mv"""
     sql """drop table if exists ivm_bm_fb_t"""
 
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_column_fallback_reason.groovy
 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_column_fallback_reason.groovy
index 2019247d816..5d63b4bdf7b 100644
--- 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_column_fallback_reason.groovy
+++ 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_column_fallback_reason.groovy
@@ -18,7 +18,7 @@
 import org.awaitility.Awaitility
 import static java.util.concurrent.TimeUnit.SECONDS
 
-suite("test_ivm_drop_column_fallback_reason", "nonConcurrent") {
+suite("test_ivm_drop_column_fallback_reason") {
     if (isCloudMode()) {
         return
     }
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_minmax_runtime_fallback.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_minmax_runtime_fallback.groovy
index 1ee2153a53e..4bc1f069d78 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_minmax_runtime_fallback.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_minmax_runtime_fallback.groovy
@@ -18,7 +18,7 @@
 import org.awaitility.Awaitility
 import static java.util.concurrent.TimeUnit.SECONDS
 
-suite("test_ivm_minmax_runtime_fallback", "nonConcurrent") {
+suite("test_ivm_minmax_runtime_fallback") {
     sql """drop materialized view if exists ivm_mm_fb_mv"""
     sql """drop table if exists ivm_mm_fb_t"""
 
diff --git a/regression-test/suites/mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy
index 5e272898f06..94828aed649 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_ivm_mtmv_row_binlog", "nonConcurrent") {
+suite("test_ivm_mtmv_row_binlog") {
     if (isCloudMode()) {
         return
     }
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild.groovy
index 150856e3f16..488e7bd44c3 100644
--- 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild.groovy
+++ 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_ivm_partition_baseline_rebuild", "nonConcurrent") {
+suite("test_ivm_partition_baseline_rebuild") {
     def tableName = "ivm_part_rebuild_t"
     def mvName = "ivm_part_rebuild_mv"
 
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit.groovy
new file mode 100644
index 00000000000..24009d05987
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit.groovy
@@ -0,0 +1,159 @@
+// 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.
+
+/**
+ * An MV that keeps only part of the base table's partitions must not break 
the incremental
+ * refresh when a dimension change touches base rows that live outside that 
window.
+ *
+ * <p>The MV below is partitioned by the fact table's dt and keeps only 
partitions whose range
+ * upper bound is greater than the start of the current year 
(partition_sync_limit=1 with
+ * partition_sync_time_unit=YEAR), so p_dropped is filtered out of the MV 
while p_kept stays.
+ * Its partition set is therefore a strict subset of the base table's.
+ *
+ * <p>A late-arriving dimension row for key 99 produces a delta that joins the 
dimension events
+ * against the fact snapshot; the snapshot covers every fact partition, so it 
also matches the
+ * fact row stored in p_dropped and emits a delta row for a date the MV has no 
partition for.
+ * The insert then fails with "no partition for this tuple", and because the 
write is atomic the
+ * in-window repair is lost as well. The refresh must instead ignore the parts 
of the delta that
+ * fall outside the MV's partition set and still repair the partition it does 
keep.
+ *
+ * <p>All dates are literals and every partition is created by hand: no 
current_date() and no
+ * dynamic partition scheduler, so the expectation does not depend on the run 
date. Only the
+ * YEAR unit is used to keep the kept/dropped split stable for any run date in 
this century.
+ */
+suite("test_ivm_partition_sync_limit") {
+    def factTable = "ivm_pwld_f"
+    def dimTable = "ivm_pwld_d"
+    def mvName = "ivm_pwld_mv"
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+    sql """DROP TABLE IF EXISTS ${factTable}"""
+    sql """DROP TABLE IF EXISTS ${dimTable}"""
+
+    sql """
+        CREATE TABLE ${factTable} (
+            order_id BIGINT NOT NULL,
+            dt DATE NOT NULL,
+            dimension_id INT,
+            amount INT
+        )
+        UNIQUE KEY(order_id, dt)
+        PARTITION BY RANGE(dt) ()
+        DISTRIBUTED BY HASH(order_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"
+        )
+    """
+    // Empty PARTITION BY range plus manual ADD PARTITION: the partition 
layout does not depend
+    // on the date the suite runs.
+    sql """ALTER TABLE ${factTable} ADD PARTITION p_dropped VALUES 
[('2019-01-01'), ('2020-01-01'))"""
+    sql """ALTER TABLE ${factTable} ADD PARTITION p_kept VALUES 
[('2026-01-01'), ('2099-01-01'))"""
+
+    sql """
+        CREATE TABLE ${dimTable} (
+            dimension_id INT NOT NULL,
+            dimension_name VARCHAR(32)
+        )
+        UNIQUE KEY(dimension_id)
+        DISTRIBUTED BY HASH(dimension_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 """INSERT INTO ${dimTable} VALUES (10, 'known')"""
+    // order_id 1 and 2 share dimension key 99, which has no dimension row 
yet: order_id 1 sits in
+    // the partition the MV does not keep, order_id 2 in the one it does.
+    sql """INSERT INTO ${factTable} VALUES
+            (1, '2019-06-01', 99, 10),
+            (2, '2026-06-15', 99, 20),
+            (3, '2026-06-16', 10, 30)"""
+
+    sql """
+        CREATE MATERIALIZED VIEW ${mvName}
+        BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+        KEY(order_id, dt)
+        PARTITION BY(dt)
+        DISTRIBUTED BY HASH(order_id) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1",
+            "partition_sync_limit" = "1",
+            "partition_sync_time_unit" = "YEAR"
+        )
+        AS SELECT f.order_id, f.dt, f.amount, d.dimension_name
+           FROM ${factTable} f
+           LEFT JOIN ${dimTable} d ON f.dimension_id = d.dimension_id
+    """
+
+    // Waiting through the framework helper rather than by reading the newest 
row: tasks() can
+    // briefly miss a task that just finished, and this suite reuses the MV 
name across runs, so
+    // the newest row can be another run's task. Only the id is taken here; 
the row the .out
+    // compares comes from taskQuery below.
+    def refreshAndGetTaskId = { String mode ->
+        sql """REFRESH MATERIALIZED VIEW ${mvName} ${mode}"""
+        waitingMTMVTaskFinishedByMvName(mvName)
+        def rows = sql_return_maparray("""
+            SELECT TaskId FROM tasks('type'='mv')
+            WHERE MvDatabaseName = '${context.dbName}' AND MvName = '${mvName}'
+            ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+        """)
+        assert !rows.isEmpty(): "no refresh task for ${mode} on ${mvName}"
+        return rows[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}'
+        """
+    }
+
+    // The MV keeps p_kept only, so order_id 1 is never part of it.
+    def taskId = refreshAndGetTaskId("COMPLETE")
+    qt_complete_task taskQuery(taskId)
+    order_qt_complete_mv """
+        SELECT order_id, amount, dimension_name FROM ${mvName} ORDER BY 
order_id
+    """
+
+    // A late-arriving dimension row for key 99. Its delta must repair 
order_id 2 (in p_kept) and
+    // must not try to write order_id 1 into a partition the MV does not have.
+    sql """INSERT INTO ${dimTable} VALUES (99, 'late-arriving')"""
+    taskId = refreshAndGetTaskId("INCREMENTAL")
+    qt_incremental_task taskQuery(taskId)
+    order_qt_incremental_mv """
+        SELECT order_id, amount, dimension_name FROM ${mvName} ORDER BY 
order_id
+    """
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+    sql """DROP TABLE IF EXISTS ${factTable}"""
+    sql """DROP TABLE IF EXISTS ${dimTable}"""
+}
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.groovy
 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.groovy
new file mode 100644
index 00000000000..2713db9333f
--- /dev/null
+++ 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.groovy
@@ -0,0 +1,157 @@
+// 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.
+
+/**
+ * partition_sync_limit and ivm_partition_window_limit together: the base 
partitions the
+ * incremental delta may read are the INTERSECTION of the two.
+ *
+ * <p>The MV keeps the partitions above the start of the current year 
(partition_sync_limit=1 with
+ * YEAR), which is p_mid and p_new, while p_dropped is filtered out. The 
compute window keeps the
+ * last partition by value, which is p_new alone.
+ *
+ * <p>So only p_new may be read: p_dropped must stay unread, or the delta 
would emit rows for a
+ * date the MV has no partition for, and p_mid must stay unread too, or the 
window would be
+ * ignored and the MV would be maintained outside it. A dimension change 
touches one fact row in
+ * each partition, so the expectation separates the three: p_new is repaired, 
p_mid keeps the
+ * value the window says not to maintain, p_dropped is not part of the MV at 
all.
+ *
+ * <p>All dates are literals and every partition is created by hand: no 
current_date() and no
+ * dynamic partition scheduler. The YEAR unit and the far-future range upper 
bounds keep the
+ * kept/dropped split stable for any run date in this century.
+ */
+suite("test_ivm_partition_sync_limit_with_window") {
+    def factTable = "ivm_pslw_f"
+    def dimTable = "ivm_pslw_d"
+    def mvName = "ivm_pslw_mv"
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+    sql """DROP TABLE IF EXISTS ${factTable}"""
+    sql """DROP TABLE IF EXISTS ${dimTable}"""
+
+    sql """
+        CREATE TABLE ${factTable} (
+            order_id BIGINT NOT NULL,
+            dt DATE NOT NULL,
+            dimension_id INT,
+            amount INT
+        )
+        UNIQUE KEY(order_id, dt)
+        PARTITION BY RANGE(dt) ()
+        DISTRIBUTED BY HASH(order_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 ${factTable} ADD PARTITION p_dropped VALUES 
[('2019-01-01'), ('2020-01-01'))"""
+    sql """ALTER TABLE ${factTable} ADD PARTITION p_mid VALUES 
[('2026-01-01'), ('2099-01-01'))"""
+    sql """ALTER TABLE ${factTable} ADD PARTITION p_new VALUES 
[('2099-01-01'), ('2199-01-01'))"""
+
+    sql """
+        CREATE TABLE ${dimTable} (
+            dimension_id INT NOT NULL,
+            dimension_name VARCHAR(32)
+        )
+        UNIQUE KEY(dimension_id)
+        DISTRIBUTED BY HASH(dimension_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 """INSERT INTO ${dimTable} VALUES (10, 'known')"""
+    // One fact row per partition, all on dimension key 99, which has no 
dimension row yet.
+    sql """INSERT INTO ${factTable} VALUES
+            (1, '2019-06-01', 99, 10),
+            (2, '2026-06-15', 99, 20),
+            (3, '2099-06-15', 99, 30)"""
+
+    sql """
+        CREATE MATERIALIZED VIEW ${mvName}
+        BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+        KEY(order_id, dt)
+        PARTITION BY(dt)
+        DISTRIBUTED BY HASH(order_id) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1",
+            "partition_sync_limit" = "1",
+            "partition_sync_time_unit" = "YEAR",
+            "ivm_partition_window_limit" = "${factTable}:1"
+        )
+        AS SELECT f.order_id, f.dt, f.amount, d.dimension_name
+           FROM ${factTable} f
+           LEFT JOIN ${dimTable} d ON f.dimension_id = d.dimension_id
+    """
+
+    // Waiting through the framework helper rather than by reading the newest 
row: tasks() can
+    // briefly miss a task that just finished, and this suite reuses the MV 
name across runs, so
+    // the newest row can be another run's task. Only the id is taken here; 
the row the .out
+    // compares comes from taskQuery below.
+    def refreshAndGetTaskId = { String mode ->
+        sql """REFRESH MATERIALIZED VIEW ${mvName} ${mode}"""
+        waitingMTMVTaskFinishedByMvName(mvName)
+        def rows = sql_return_maparray("""
+            SELECT TaskId FROM tasks('type'='mv')
+            WHERE MvDatabaseName = '${context.dbName}' AND MvName = '${mvName}'
+            ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+        """)
+        assert !rows.isEmpty(): "no refresh task for ${mode} on ${mvName}"
+        return rows[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}'
+        """
+    }
+
+    // The MV keeps p_mid and p_new, so order_id 1 is never part of it. The 
window is an
+    // incremental-path property, so the complete refresh still builds both MV 
partitions.
+    sql """REFRESH MATERIALIZED VIEW ${mvName} COMPLETE"""
+    def taskId = refreshAndGetTaskId("COMPLETE")
+    qt_complete_task taskQuery(taskId)
+    order_qt_complete_mv """
+        SELECT order_id, amount, dimension_name FROM ${mvName} ORDER BY 
order_id
+    """
+
+    // A late-arriving dimension row for key 99 touches the fact row in every 
partition.
+    sql """INSERT INTO ${dimTable} VALUES (99, 'late-arriving')"""
+    taskId = refreshAndGetTaskId("INCREMENTAL")
+    qt_incremental_task taskQuery(taskId)
+    order_qt_incremental_mv """
+        SELECT order_id, amount, dimension_name FROM ${mvName} ORDER BY 
order_id
+    """
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+    sql """DROP TABLE IF EXISTS ${factTable}"""
+    sql """DROP TABLE IF EXISTS ${dimTable}"""
+}
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_rewrite_projection.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_rewrite_projection.groovy
index cf8d21b7831..fd9f41e168d 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_rewrite_projection.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_rewrite_projection.groovy
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-suite("test_ivm_rewrite_projection", "nonConcurrent") {
+suite("test_ivm_rewrite_projection") {
     sql """drop materialized view if exists rewrite_projection_ivm;"""
     sql """drop table if exists rewrite_projection_base;"""
 
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_strict_failure_partition_atomicity.groovy
 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_strict_failure_partition_atomicity.groovy
index 1b7acd59eca..13b9edc7e5c 100644
--- 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_strict_failure_partition_atomicity.groovy
+++ 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_strict_failure_partition_atomicity.groovy
@@ -18,7 +18,7 @@
 import org.awaitility.Awaitility
 import static java.util.concurrent.TimeUnit.SECONDS
 
-suite("test_ivm_strict_failure_partition_atomicity", "nonConcurrent") {
+suite("test_ivm_strict_failure_partition_atomicity") {
     sql """DROP MATERIALIZED VIEW IF EXISTS ivm_strict_atomicity_mv"""
     sql """DROP TABLE IF EXISTS ivm_strict_atomicity_t"""
     sql """


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

Reply via email to