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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new ea2df20d2fc [fix](mtmv) Eliminate redundant filters in nested MV 
candidate defini… (#69381)
ea2df20d2fc is described below

commit ea2df20d2fce98342c447961363bbd84c4038278
Author: foxtail463 <[email protected]>
AuthorDate: Thu Oct 29 09:46:21 2026 +0800

    [fix](mtmv) Eliminate redundant filters in nested MV candidate defini… 
(#69381)
    
    Cherry-picked from #63639
    
    ---------
    
    Co-authored-by: yangtao555 <[email protected]>
---
 .../main/java/org/apache/doris/catalog/MTMV.java   | 128 +++--
 .../main/java/org/apache/doris/mtmv/MTMVCache.java |  36 +-
 .../java/org/apache/doris/mtmv/MTMVPlanUtil.java   |  17 +-
 .../org/apache/doris/nereids/StatementContext.java |  11 +
 .../nereids/rules/analysis/ExpressionAnalyzer.java |  15 +-
 .../exploration/mv/MaterializationContext.java     |   7 +-
 .../mv/MaterializedViewPredicateCollector.java     | 124 ++++
 .../nereids/rules/exploration/mv/Predicates.java   |  16 +
 .../nereids/rules/exploration/mv/StructInfo.java   |  41 ++
 .../nereids/rules/rewrite/PullUpPredicates.java    |   2 +-
 .../doris/mtmv/MTMVOutputPredicatesTest.java       | 223 ++++++++
 .../org/apache/doris/nereids/mv/MTMVCacheTest.java | 147 +++++
 .../nereids/mv/RelationImpliedPredicateTest.java   | 627 +++++++++++++++++++++
 .../relation_implied_predicate_nested_mtmv.out     |   8 +
 .../relation_implied_predicate_nested_mtmv.groovy  | 120 ++++
 15 files changed, 1459 insertions(+), 63 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 9933481475b..24fe0adb30f 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
@@ -92,6 +92,8 @@ public class MTMV extends OlapTable {
     private MTMVCache cacheWithGuard;
     // Cache without SessionVarGuardExpr: used when query session variables 
match MV creation variables
     private MTMVCache cacheWithoutGuard;
+    // Increased on invalidation to reject caches built against an earlier 
definition or refresh.
+    private transient long rewriteCacheGeneration;
     private long schemaChangeVersion;
     @SerializedName(value = "sv")
     private Map<String, String> sessionVariables;
@@ -189,6 +191,7 @@ public class MTMV extends OlapTable {
             // only can update state, refresh state will be change by add task
             this.schemaChangeVersion++;
             this.refreshSnapshot = new MTMVRefreshSnapshot();
+            invalidateRewriteCache();
             return this.status.updateStateAndDetail(newStatus);
         } finally {
             writeMvUnlock();
@@ -196,15 +199,7 @@ public class MTMV extends OlapTable {
     }
 
     public void processBaseViewChange(String schemaChangeDetail) {
-        writeMvLock();
-        try {
-            this.schemaChangeVersion++;
-            this.status.setState(MTMVState.SCHEMA_CHANGE);
-            this.status.setSchemaChangeDetail(schemaChangeDetail);
-            this.refreshSnapshot = new MTMVRefreshSnapshot();
-        } finally {
-            writeMvUnlock();
-        }
+        alterStatus(new MTMVStatus(MTMVState.SCHEMA_CHANGE, 
schemaChangeDetail));
     }
 
     public boolean addTaskResult(MTMVTask task, MTMVRelation relation,
@@ -212,9 +207,16 @@ public class MTMV extends OlapTable {
         MTMVCache mtmvCacheWithGuard = null;
         MTMVCache mtmvCacheWithoutGuard = null;
         boolean needUpdateCache = false;
+        long cacheGeneration = -1;
         if (task.getStatus() == TaskStatus.SUCCESS && !Env.isCheckpointThread()
                 && !Config.enable_check_compatibility_mode) {
             needUpdateCache = true;
+            readMvLock();
+            try {
+                cacheGeneration = rewriteCacheGeneration;
+            } finally {
+                readMvUnlock();
+            }
             try {
                 // The replay thread may not have initialized the catalog yet 
to avoid getting stuck due
                 // to connection issues such as S3, so it is directly set to 
null
@@ -222,12 +224,8 @@ public class MTMV extends OlapTable {
                     ConnectContext currentContext = ConnectContext.get();
                     // shouldn't do this while holding mvWriteLock
                     // TODO: these two cache compute share something same, can 
be simplified in future
-                    mtmvCacheWithGuard = MTMVCache.from(this.getQuerySql(),
-                            MTMVPlanUtil.createMTMVContext(this, 
MTMVPlanUtil.DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE),
-                            true, true, currentContext, true);
-                    mtmvCacheWithoutGuard = MTMVCache.from(this.getQuerySql(),
-                            MTMVPlanUtil.createMTMVContext(this, 
MTMVPlanUtil.DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE),
-                            true, true, currentContext, false);
+                    mtmvCacheWithGuard = createRewriteCache(currentContext, 
true, true);
+                    mtmvCacheWithoutGuard = createRewriteCache(currentContext, 
true, false);
                 }
             } catch (Throwable e) {
                 mtmvCacheWithGuard = null;
@@ -245,23 +243,31 @@ public class MTMV extends OlapTable {
                         name, task.getTaskId(), 
task.getMtmvSchemaChangeVersion(), this.schemaChangeVersion);
                 return false;
             }
+            boolean publishCache = false;
             if (task.getStatus() == TaskStatus.SUCCESS) {
+                publishCache = needUpdateCache && cacheGeneration == 
rewriteCacheGeneration;
+                // A successful refresh changes the snapshot coverage and 
supersedes in-flight builds.
+                invalidateRewriteCache();
                 this.status.setState(MTMVState.NORMAL);
                 this.status.setSchemaChangeDetail(null);
                 this.status.setRefreshState(MTMVRefreshState.SUCCESS);
                 this.relation = relation;
-                if (needUpdateCache) {
-                    // Initialize cacheWithGuard, cacheWithoutGuard will be 
lazily generated when needed
-                    this.cacheWithGuard = mtmvCacheWithGuard;
-                    // Clear the other cache to ensure consistency
-                    this.cacheWithoutGuard = mtmvCacheWithoutGuard;
-                }
             } else {
                 this.status.setRefreshState(MTMVRefreshState.FAIL);
             }
             this.jobInfo.addHistoryTask(task);
             compatiblePctSnapshot(partitionSnapshots);
             this.refreshSnapshot.updateSnapshots(partitionSnapshots, 
getPartitionNames());
+            if (publishCache) {
+                // Publish after recording coverage: NORMAL after a partial 
refresh does not prove
+                // that every stored partition already uses the current 
definition.
+                if (mtmvCacheWithGuard != null) {
+                    this.cacheWithGuard = 
cacheWithValidOutputPredicates(mtmvCacheWithGuard);
+                }
+                if (mtmvCacheWithoutGuard != null) {
+                    this.cacheWithoutGuard = 
cacheWithValidOutputPredicates(mtmvCacheWithoutGuard);
+                }
+            }
             Env.getCurrentEnv().getMtmvService()
                     .refreshComplete(this, relation, task);
             return true;
@@ -286,6 +292,7 @@ public class MTMV extends OlapTable {
                     // snapshots so the next AUTO refresh rebuilds a complete 
baseline with the new rules.
                     this.schemaChangeVersion++;
                     this.refreshSnapshot = new MTMVRefreshSnapshot();
+                    invalidateRewriteCache();
                 }
             }
             return this.mvProperties;
@@ -424,38 +431,62 @@ public class MTMV extends OlapTable {
         boolean sessionVarsMatch = 
SessionVarGuardRewriter.checkSessionVariablesMatch(
                 currentSessionVars, this.sessionVariables);
 
-        // Select appropriate cache based on session variable match
-        readMvLock();
-        try {
-            if (sessionVarsMatch && cacheWithoutGuard != null) {
-                return cacheWithoutGuard;
+        while (true) {
+            long cacheGeneration;
+            readMvLock();
+            try {
+                MTMVCache cached = sessionVarsMatch ? cacheWithoutGuard : 
cacheWithGuard;
+                if (cached != null) {
+                    return cached;
+                }
+                cacheGeneration = rewriteCacheGeneration;
+            } finally {
+                readMvUnlock();
             }
-            if (!sessionVarsMatch && cacheWithGuard != null) {
-                return cacheWithGuard;
+
+            // Build outside the MV lock to avoid nesting it with table locks 
during analysis.
+            MTMVCache generated = createRewriteCache(connectionContext, false, 
!sessionVarsMatch);
+            writeMvLock();
+            try {
+                if (cacheGeneration != rewriteCacheGeneration) {
+                    continue;
+                }
+                MTMVCache cached = sessionVarsMatch ? cacheWithoutGuard : 
cacheWithGuard;
+                if (cached != null) {
+                    return cached;
+                }
+                MTMVCache published = 
cacheWithValidOutputPredicates(generated);
+                if (sessionVarsMatch) {
+                    this.cacheWithoutGuard = published;
+                } else {
+                    this.cacheWithGuard = published;
+                }
+                return published;
+            } finally {
+                writeMvUnlock();
             }
-        } finally {
-            readMvUnlock();
         }
+    }
 
-        // Generate cache if not exists
-        // Concurrent situations may result in duplicate cache generation,
-        // but we tolerate this in order to prevent nested use of readLock and 
write MvLock for the table
-        MTMVCache mtmvCache = MTMVCache.from(this.getQuerySql(),
-                MTMVPlanUtil.createMTMVContext(this, 
MTMVPlanUtil.DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE),
-                true, false, connectionContext, !sessionVarsMatch);
+    /** Invalidate both cache variants and reject any older build still in 
progress. */
+    public void invalidateRewriteCache() {
         writeMvLock();
         try {
-            if (sessionVarsMatch) {
-                this.cacheWithoutGuard = mtmvCache;
-            } else {
-                this.cacheWithGuard = mtmvCache;
-            }
-            return mtmvCache;
+            rewriteCacheGeneration++;
+            cacheWithGuard = null;
+            cacheWithoutGuard = null;
         } finally {
             writeMvUnlock();
         }
     }
 
+    protected MTMVCache createRewriteCache(ConnectContext currentContext, 
boolean needLock,
+            boolean addSessionVarGuard) {
+        return MTMVCache.from(this.getQuerySql(),
+                MTMVPlanUtil.createMTMVContext(this, 
MTMVPlanUtil.DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE),
+                true, needLock, currentContext, addSessionVarGuard);
+    }
+
     public Map<String, String> getMvProperties() {
         readMvLock();
         try {
@@ -601,6 +632,15 @@ public class MTMV extends OlapTable {
         this.mvRwLock.writeLock().unlock();
     }
 
+    private MTMVCache cacheWithValidOutputPredicates(MTMVCache cache) {
+        // Callers hold the MV lock while publishing guarantees about the 
stored partitions.
+        if (status.getState() == MTMVState.SCHEMA_CHANGE
+                || 
!refreshSnapshot.getPartitionSnapshots().keySet().containsAll(getPartitionNames()))
 {
+            return cache.withoutOutputPredicates();
+        }
+        return cache;
+    }
+
     // toString() is not easy to find where to call the method
     public String toInfoString() {
         final StringBuilder sb = new StringBuilder("MTMV{");
@@ -638,8 +678,8 @@ public class MTMV extends OlapTable {
             Env.getCurrentEnv().getMtmvService().registerMTMV(this, 
this.getDatabase().getId());
         } catch (Throwable e) {
             LOG.warn("MTMV compatible failed, dbName: {}, mvName: {}, errMsg: 
{}", getDBName(), name, e.getMessage());
-            status.setState(MTMVState.SCHEMA_CHANGE);
-            status.setSchemaChangeDetail("compatible failed, please refresh or 
recreate it, reason: " + e.getMessage());
+            alterStatus(new MTMVStatus(MTMVState.SCHEMA_CHANGE,
+                    "compatible failed, please refresh or recreate it, reason: 
" + e.getMessage()));
         }
     }
 
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVCache.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVCache.java
index 391a9546500..12108927648 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVCache.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVCache.java
@@ -32,6 +32,8 @@ import 
org.apache.doris.nereids.rules.exploration.mv.MaterializedViewUtils;
 import org.apache.doris.nereids.rules.exploration.mv.StructInfo;
 import org.apache.doris.nereids.rules.rewrite.EliminateSort;
 import org.apache.doris.nereids.rules.rewrite.MergeProjectable;
+import org.apache.doris.nereids.rules.rewrite.PullUpPredicates;
+import org.apache.doris.nereids.trees.expressions.Expression;
 import org.apache.doris.nereids.trees.plans.Plan;
 import 
org.apache.doris.nereids.trees.plans.commands.ExplainCommand.ExplainLevel;
 import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
@@ -44,12 +46,14 @@ import org.apache.doris.qe.OriginStatement;
 import org.apache.doris.statistics.Statistics;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
 
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 
 /**
  * The cache for materialized view cache
@@ -65,13 +69,17 @@ public class MTMVCache {
     private final Plan originalFinalPlan;
     private final Statistics statistics;
     private final List<Pair<Plan, StructInfo>> 
partRulesRewrittenPlanAndStructInfos;
+    // Guarantees expressed only in the definition's output slots, including 
session-variable guards.
+    private final Set<Expression> outputPredicates;
 
     public MTMVCache(Pair<Plan, StructInfo> 
allRulesRewrittenPlanAndStructInfo, Plan originalFinalPlan,
-            Statistics statistics, List<Pair<Plan, StructInfo>> 
partRulesRewrittenPlanAndStructInfos) {
+            Statistics statistics, List<Pair<Plan, StructInfo>> 
partRulesRewrittenPlanAndStructInfos,
+            Set<Expression> outputPredicates) {
         this.allRulesRewrittenPlanAndStructInfo = 
allRulesRewrittenPlanAndStructInfo;
         this.originalFinalPlan = originalFinalPlan;
         this.statistics = statistics;
         this.partRulesRewrittenPlanAndStructInfos = 
partRulesRewrittenPlanAndStructInfos;
+        this.outputPredicates = ImmutableSet.copyOf(outputPredicates);
     }
 
     public Pair<Plan, StructInfo> getAllRulesRewrittenPlanAndStructInfo() {
@@ -82,6 +90,16 @@ public class MTMVCache {
         return originalFinalPlan;
     }
 
+    public Set<Expression> getOutputPredicates() {
+        return outputPredicates;
+    }
+
+    /** Keep the definition plan without claiming that its predicates hold for 
stored rows. */
+    public MTMVCache withoutOutputPredicates() {
+        return new MTMVCache(allRulesRewrittenPlanAndStructInfo, 
originalFinalPlan,
+                statistics, partRulesRewrittenPlanAndStructInfos, 
ImmutableSet.of());
+    }
+
     public Statistics getStatistics() {
         return statistics;
     }
@@ -142,6 +160,19 @@ public class MTMVCache {
                     .orElse(rewritePlan);
             Pair<Plan, StructInfo> finalPlanStructInfoPair = 
constructPlanAndStructInfo(
                     addGuardRewritePlan, cascadesContext);
+            // Analysis records nondeterministic functions before constant 
folding erases their origin.
+            // Replanning current_date(), for example, cannot establish a 
guarantee about rows stored
+            // by an earlier refresh. Do not export output guarantees from 
such a definition.
+            // Alias UDF definitions can also change between refresh and cache 
reconstruction,
+            // even when the expanded function body is deterministic.
+            // A lower MV may have refreshed since this MV's rows were stored. 
Derive guarantees
+            // from this definition alone, without importing the lower MV's 
current output facts.
+            Set<Expression> outputPredicates = 
(cascadesContext.getStatementContext().hasNondeterministic()
+                    || cascadesContext.getStatementContext().hasAliasUdf())
+                    ? ImmutableSet.of()
+                    : finalPlanStructInfoPair.key().accept(new 
PullUpPredicates(true, cascadesContext), null).stream()
+                            .filter(predicate -> 
!predicate.containsVolatileExpression())
+                            .collect(ImmutableSet.toImmutableSet());
             List<Pair<Plan, StructInfo>> tmpPlanUsedForRewrite = new 
ArrayList<>();
             for (Plan plan : 
cascadesContext.getStatementContext().getTmpPlanForMvRewrite()) {
                 Plan addGuardplan = exprRewriter
@@ -150,7 +181,8 @@ public class MTMVCache {
                 
tmpPlanUsedForRewrite.add(constructPlanAndStructInfo(addGuardplan, 
cascadesContext));
             }
             return new MTMVCache(finalPlanStructInfoPair, addGuardRewritePlan, 
needCost
-                    ? cascadesContext.getMemo().getRoot().getStatistics() : 
null, tmpPlanUsedForRewrite);
+                    ? cascadesContext.getMemo().getRoot().getStatistics() : 
null, tmpPlanUsedForRewrite,
+                    outputPredicates);
         } finally {
             
createCacheContext.getStatementContext().setForceRecordTmpPlan(false);
             mvSqlStatementContext.setForceRecordTmpPlan(false);
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
index 8b6e3aa264a..b99787aca96 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
@@ -96,11 +96,9 @@ import javax.annotation.Nullable;
 
 public class MTMVPlanUtil {
     private static final Logger LOG = LogManager.getLogger(MTMVPlanUtil.class);
-    // The rules should be disabled when generate MTMV cache
-    // Because these rules may change the plan structure and cause the plan 
can not match the mv
-    // this is mainly for final CBO phase rewrite, pre RBO phase does not need 
to consider, because
-    // maintain tmp plan alone for rewrite when pre RBO rewrite
-    public static final List<RuleType> DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE 
= ImmutableList.of(
+    // These rules keep the generated query plan aligned with the MV 
definition while avoiding rewrites
+    // that can change its shape.
+    public static final List<RuleType> DISABLE_RULES_WHEN_RUN_MTMV_TASK = 
ImmutableList.of(
             RuleType.COMPRESSED_MATERIALIZE_AGG,
             RuleType.COMPRESSED_MATERIALIZE_SORT,
             RuleType.ELIMINATE_CONST_JOIN_CONDITION,
@@ -113,8 +111,13 @@ public class MTMVPlanUtil {
             RuleType.ELIMINATE_GROUP_BY,
             RuleType.SALT_JOIN
     );
-    // The rules should be disabled when run MTMV task
-    public static final List<RuleType> DISABLE_RULES_WHEN_RUN_MTMV_TASK = 
DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE;
+
+    // Preserve the definition's scans and filters even when source partitions 
currently have no data.
+    // Empty-partition pruning would erase predicates needed for MV matching 
and output guarantees.
+    public static final List<RuleType> DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE 
= ImmutableList.<RuleType>builder()
+            .addAll(DISABLE_RULES_WHEN_RUN_MTMV_TASK)
+            .add(RuleType.PRUNE_EMPTY_PARTITION)
+            .build();
 
     public static ConnectContext createMTMVContext(MTMV mtmv, List<RuleType> 
disableRules) {
         ConnectContext ctx = createBasicMvContext(null, disableRules, 
mtmv.getSessionVariables());
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
index 1e76e5dde72..9167ed889bc 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
@@ -155,6 +155,9 @@ public class StatementContext implements Closeable {
     private boolean isDelete = false;
     private boolean hasNondeterministic = false;
 
+    // Alias UDF calls disappear after expansion; retain their definition 
dependency.
+    private boolean hasAliasUdf = false;
+
     // hasUnknownColStats true if any column stats in the tables used by this 
sql is
     // unknown
     // the algorithm to derive plan when column stats are unknown is 
implemented in
@@ -550,6 +553,14 @@ public class StatementContext implements Closeable {
         return hasNondeterministic;
     }
 
+    public void recordAliasUdf() {
+        hasAliasUdf = true;
+    }
+
+    public boolean hasAliasUdf() {
+        return hasAliasUdf;
+    }
+
     public ConnectContext getConnectContext() {
         return connectContext;
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java
index 77ac4efced5..4467802f8d4 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/ExpressionAnalyzer.java
@@ -27,7 +27,6 @@ import org.apache.doris.common.util.Util;
 import org.apache.doris.mysql.MysqlCommand;
 import org.apache.doris.nereids.CascadesContext;
 import org.apache.doris.nereids.SqlCacheContext;
-import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.analyzer.Scope;
 import org.apache.doris.nereids.analyzer.UnboundAlias;
 import org.apache.doris.nereids.analyzer.UnboundFunction;
@@ -200,7 +199,12 @@ public class ExpressionAnalyzer extends 
SubExprAnalyzer<ExpressionRewriteContext
 
     /** analyze */
     public Expression analyze(Expression expression, ExpressionRewriteContext 
context) {
-        return expression.accept(this, context);
+        Expression analyzed = expression.accept(this, context);
+        // Inspect the bound tree to include parser-bound functions and 
expanded alias UDF bodies.
+        if (context != null && analyzed.containsNondeterministic()) {
+            
context.cascadesContext.getStatementContext().setHasNondeterministic(true);
+        }
+        return analyzed;
     }
 
     @Override
@@ -602,14 +606,13 @@ public class ExpressionAnalyzer extends 
SubExprAnalyzer<ExpressionRewriteContext
             });
         }
 
+        if (builder instanceof AliasUdfBuilder && context != null) {
+            context.cascadesContext.getStatementContext().recordAliasUdf();
+        }
         Pair<? extends Expression, ? extends BoundFunction> buildResult = 
builder.build(functionName, arguments);
         buildResult.second.checkOrderExprIsValid();
         Optional<SqlCacheContext> sqlCacheContext = Optional.empty();
 
-        if (!buildResult.second.isDeterministic() && context != null) {
-            StatementContext statementContext = 
context.cascadesContext.getStatementContext();
-            statementContext.setHasNondeterministic(true);
-        }
         if (wantToParseSqlFromSqlCache) {
             sqlCacheContext = 
context.cascadesContext.getStatementContext().getSqlCacheContext();
             if (builder instanceof AliasUdfBuilder
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/MaterializationContext.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/MaterializationContext.java
index 2ab84c2108b..72c1238165c 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/MaterializationContext.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/MaterializationContext.java
@@ -122,9 +122,10 @@ public abstract class MaterializationContext {
         this.enableRecordFailureDetail = parsedStatement != null && 
parsedStatement.isExplain()
                 && ExplainLevel.MEMO_PLAN == 
parsedStatement.getExplainOptions().getExplainLevel();
         // Construct materialization struct info, catch exception which may 
cause planner roll back
-        this.structInfo = structInfo == null
-                ? constructStructInfo(plan, originalPlan, 
cascadesContext).orElseGet(() -> null)
-                : structInfo;
+        this.structInfo = (structInfo == null
+                ? constructStructInfo(plan, originalPlan, cascadesContext)
+                : Optional.of(structInfo))
+                .map(info -> 
info.withoutRedundantMvFilters(cascadesContext)).orElse(null);
         this.available = this.structInfo != null;
         if (available) {
             this.planOutputShuttledExpressions = 
this.structInfo.getPlanOutputShuttledExpressions();
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/MaterializedViewPredicateCollector.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/MaterializedViewPredicateCollector.java
new file mode 100644
index 00000000000..eadb0e7a5ac
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/MaterializedViewPredicateCollector.java
@@ -0,0 +1,124 @@
+// 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.
+
+package org.apache.doris.nereids.rules.exploration.mv;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.mtmv.MTMVCache;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.rules.rewrite.PullUpPredicates;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
+import org.apache.doris.nereids.util.ExpressionUtils;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableSet;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Derive predicates for MV matching using guarantees on materialized table 
outputs.
+ *
+ * <p>The definition cache owns the guarantees; each visit binds them to this 
scan's actual slots.
+ * No expression state is attached to a scan, so ordinary scans, replacement 
scans and deep copies
+ * follow the same path. PullUpPredicates retains the operator boundaries, 
including null extension
+ * by outer joins and columns removed by projections or aggregation.
+ */
+public final class MaterializedViewPredicateCollector extends PullUpPredicates 
{
+    private static final Logger LOG = 
LogManager.getLogger(MaterializedViewPredicateCollector.class);
+
+    private final CascadesContext cascadesContext;
+
+    public MaterializedViewPredicateCollector(CascadesContext cascadesContext) 
{
+        super(true, cascadesContext);
+        this.cascadesContext = cascadesContext;
+    }
+
+    /** Derive guarantees on a filter's input for the current MV matching 
analysis. */
+    public Set<Expression> collect(Plan plan) {
+        return plan.accept(this, null).stream()
+                .filter(predicate -> !predicate.containsVolatileExpression())
+                .collect(ImmutableSet.toImmutableSet());
+    }
+
+    @Override
+    public ImmutableSet<Expression> visitLogicalOlapScan(LogicalOlapScan scan, 
Void context) {
+        return cacheOrElse(scan, () -> {
+            if (!(scan.getTable() instanceof MTMV)
+                    || scan.getSelectedIndexId() != 
scan.getTable().getBaseIndexId()) {
+                return ImmutableSet.of();
+            }
+            MTMV mtmv = (MTMV) scan.getTable();
+            MTMVCache cache;
+            try {
+                cache = 
mtmv.getOrGenerateCache(cascadesContext.getConnectContext());
+            } catch (Exception exception) {
+                // A stored MV remains readable when its definition can no 
longer be planned.
+                // As with MV data-trait derivation, unavailable metadata 
supplies no rewrite proof.
+                LOG.warn("Cannot derive output predicates for materialized 
view {}", mtmv.getName(), exception);
+                return ImmutableSet.of();
+            }
+            if (cache.getOutputPredicates().isEmpty()) {
+                return ImmutableSet.of();
+            }
+            return 
ImmutableSet.copyOf(ExpressionUtils.replace(cache.getOutputPredicates(),
+                    mapDefinitionOutputs(cache, scan)));
+        });
+    }
+
+    private static Map<Slot, Slot> mapDefinitionOutputs(MTMVCache cache, 
LogicalOlapScan scan) {
+        List<Slot> definitionOutputs = 
cache.getOriginalFinalPlan().getOutput().stream()
+                .filter(slot -> slot instanceof SlotReference && 
((SlotReference) slot).isVisible())
+                .collect(Collectors.toList());
+        List<Column> physicalColumns = scan.getTable().getBaseSchema(false);
+        Preconditions.checkState(definitionOutputs.size() == 
physicalColumns.size(),
+                "materialized view definition output size %s does not match 
visible schema size %s",
+                definitionOutputs.size(), physicalColumns.size());
+
+        Map<String, Slot> scanColumns = new HashMap<>();
+        for (Slot slot : scan.getOutput()) {
+            if (slot instanceof SlotReference) {
+                SlotReference reference = (SlotReference) slot;
+                // Hidden IVM columns, derived variant paths and virtual 
columns are not definition outputs.
+                if (reference.isVisible() && reference.getSubPath().isEmpty()) 
{
+                    reference.getOriginalColumn().ifPresent(column ->
+                            
scanColumns.put(column.getName().toLowerCase(Locale.ROOT), reference));
+                }
+            }
+        }
+        Map<Slot, Slot> outputMapping = new HashMap<>();
+        for (int i = 0; i < physicalColumns.size(); i++) {
+            String columnName = physicalColumns.get(i).getName();
+            Slot scanSlot = 
Preconditions.checkNotNull(scanColumns.get(columnName.toLowerCase(Locale.ROOT)),
+                    "materialized view scan does not output physical column 
%s", columnName);
+            // Repeated definition outputs need one representative, not all 
combinations of scan slots.
+            outputMapping.putIfAbsent(definitionOutputs.get(i), scanSlot);
+        }
+        return outputMapping;
+    }
+}
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
index f22429dc08a..10088fd1b7a 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/Predicates.java
@@ -424,6 +424,22 @@ public class Predicates {
         return ImmutableMap.copyOf(remainingPredicates);
     }
 
+    /** Whether output guarantees prove that a filter predicate is redundant 
at its input. */
+    static boolean isImpliedByOutput(Set<Expression> outputPredicates, 
Expression predicate) {
+        if (outputPredicates.isEmpty() || 
predicate.containsVolatileExpression()) {
+            return false;
+        }
+        if (outputPredicates.contains(predicate)) {
+            return true;
+        }
+        try {
+            return impliesByDnf(ExpressionUtils.and(outputPredicates), 
predicate);
+        } catch (DnfBranchOverflowException exception) {
+            // Keep the filter when proving it would exceed the existing 
implication budget.
+            return false;
+        }
+    }
+
     private static boolean impliesByDnf(Expression source, Expression target) {
         // Check whether source => target.
         List<Set<Expression>> sourceBranches = extractDnfBranches(source);
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/StructInfo.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/StructInfo.java
index d4e0e3f5003..b53468c5725 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/StructInfo.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/exploration/mv/StructInfo.java
@@ -17,6 +17,7 @@
 
 package org.apache.doris.nereids.rules.exploration.mv;
 
+import org.apache.doris.catalog.MTMV;
 import org.apache.doris.common.Pair;
 import org.apache.doris.mtmv.BaseColInfo;
 import org.apache.doris.mtmv.BaseTableInfo;
@@ -65,8 +66,10 @@ import 
org.apache.doris.nereids.trees.plans.logical.LogicalWindow;
 import org.apache.doris.nereids.trees.plans.visitor.DefaultPlanRewriter;
 import org.apache.doris.nereids.trees.plans.visitor.DefaultPlanVisitor;
 import org.apache.doris.nereids.util.ExpressionUtils;
+import org.apache.doris.nereids.util.PlanUtils;
 
 import com.google.common.collect.HashMultimap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Sets;
 
@@ -338,6 +341,44 @@ public class StructInfo {
                 relationBitSet, null, null, planOutputShuttledExpressions);
     }
 
+    /**
+     * Simplify a materialization definition using its inputs' current 
guarantees.
+     * Query predicates must remain available for compensation when a 
candidate supplies stale data.
+     * The result belongs to the current query, not the definition cache: an 
input MV can refresh
+     * independently and invalidate the reason why a definition filter was 
redundant.
+     */
+    public StructInfo withoutRedundantMvFilters(CascadesContext 
cascadesContext) {
+        Plan normalized = removeRedundantMvFilters(topPlan, cascadesContext);
+        return normalized == topPlan ? this : of(normalized, originalPlan, 
cascadesContext);
+    }
+
+    private static Plan removeRedundantMvFilters(Plan plan, CascadesContext 
cascadesContext) {
+        if (!plan.anyMatch(node -> node instanceof LogicalOlapScan
+                && ((LogicalOlapScan) node).getTable() instanceof MTMV)) {
+            return plan;
+        }
+        MaterializedViewPredicateCollector collector = new 
MaterializedViewPredicateCollector(cascadesContext);
+        Plan normalized = plan.accept(new DefaultPlanRewriter<Void>() {
+            @Override
+            public Plan visitLogicalFilter(LogicalFilter<? extends Plan> 
filter, Void context) {
+                filter = visitChildren(this, filter, context);
+                // Prove redundancy at the filter's own input, before a parent 
Project can discard
+                // the predicate column. Facts from the filter itself must not 
participate in this proof.
+                Set<Expression> guaranteed = collector.collect(filter.child());
+                Set<Expression> remaining = filter.getConjuncts().stream()
+                        .filter(predicate -> 
!Predicates.isImpliedByOutput(guaranteed, predicate))
+                        .collect(ImmutableSet.toImmutableSet());
+                if (remaining.size() == filter.getConjuncts().size()) {
+                    return filter;
+                }
+                return PlanUtils.filterOrSelf(remaining, filter.child());
+            }
+        }, null);
+        // This equivalent tree is only used to build the matching graph; the 
memo is not modified.
+        // Removing a root Filter must not assign its child's group to the 
matched expression.
+        return normalized == plan ? plan : 
normalized.withGroupExpression(plan.getGroupExpression());
+    }
+
     public List<CatalogRelation> getRelations() {
         return relations;
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PullUpPredicates.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PullUpPredicates.java
index 941cd07f20a..c97ab95bb39 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PullUpPredicates.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/rewrite/PullUpPredicates.java
@@ -364,7 +364,7 @@ public class PullUpPredicates extends 
PlanVisitor<ImmutableSet<Expression>, Void
         });
     }
 
-    private ImmutableSet<Expression> cacheOrElse(Plan plan, 
Supplier<ImmutableSet<Expression>> predicatesSupplier) {
+    protected ImmutableSet<Expression> cacheOrElse(Plan plan, 
Supplier<ImmutableSet<Expression>> predicatesSupplier) {
         ImmutableSet<Expression> predicates = cache.get(plan);
         if (predicates != null) {
             return predicates;
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVOutputPredicatesTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVOutputPredicatesTest.java
new file mode 100644
index 00000000000..bd4055ed1e1
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVOutputPredicatesTest.java
@@ -0,0 +1,223 @@
+// 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.
+
+package org.apache.doris.mtmv;
+
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.catalog.MaterializedIndex;
+import org.apache.doris.catalog.Partition;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.catalog.SinglePartitionInfo;
+import org.apache.doris.common.Pair;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.job.common.TaskStatus;
+import org.apache.doris.job.extensions.mtmv.MTMVTask;
+import org.apache.doris.job.extensions.mtmv.MTMVTask.MTMVTaskTriggerMode;
+import org.apache.doris.job.extensions.mtmv.MTMVTaskContext;
+import org.apache.doris.mtmv.MTMVRefreshEnum.BuildMode;
+import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVRefreshState;
+import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVState;
+import org.apache.doris.mtmv.MTMVRefreshEnum.RefreshMethod;
+import org.apache.doris.mtmv.MTMVRefreshEnum.RefreshTrigger;
+import org.apache.doris.nereids.trees.expressions.Alias;
+import org.apache.doris.nereids.trees.expressions.ExprId;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.GreaterThan;
+import org.apache.doris.nereids.trees.expressions.literal.IntegerLiteral;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.RelationId;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOneRowRelation;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.thrift.TStorageType;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import java.util.HashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class MTMVOutputPredicatesTest {
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testDefinitionChangeInvalidatesPublishedGuarantees(boolean 
baseViewChange) {
+        CacheMTMV mtmv = new CacheMTMV();
+        MTMVCache beforeChange = mtmv.getOrGenerateCache(mtmv.context);
+        Assertions.assertEquals(ImmutableSet.of(mtmv.predicate), 
beforeChange.getOutputPredicates());
+
+        if (baseViewChange) {
+            mtmv.processBaseViewChange("base view definition changed");
+        } else {
+            mtmv.alterStatus(new MTMVStatus(MTMVState.SCHEMA_CHANGE, "base 
table changed"));
+        }
+
+        MTMVCache afterChange = mtmv.getOrGenerateCache(mtmv.context);
+        Assertions.assertNotSame(beforeChange, afterChange);
+        Assertions.assertTrue(afterChange.getOutputPredicates().isEmpty());
+        
Assertions.assertTrue(mtmv.getRefreshSnapshot().getPartitionSnapshots().isEmpty());
+        // Return the normalized cache that was published, not the builder's 
unqualified guarantees.
+        Assertions.assertSame(afterChange, 
mtmv.getOrGenerateCache(mtmv.context));
+        Assertions.assertSame(beforeChange.getOriginalFinalPlan(), 
afterChange.getOriginalFinalPlan());
+    }
+
+    @Test
+    void testViewChangeDiscardsInFlightGuarantees() throws Exception {
+        BlockingCacheMTMV mtmv = new BlockingCacheMTMV();
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try {
+            Future<MTMVCache> cacheFuture = executor.submit(() -> 
mtmv.getOrGenerateCache(mtmv.context));
+            Assertions.assertTrue(mtmv.firstBuildStarted.await(5, 
TimeUnit.SECONDS));
+            mtmv.processBaseViewChange("base view changed while its cache was 
building");
+            mtmv.releaseFirstBuild.countDown();
+
+            MTMVCache cache = cacheFuture.get(5, TimeUnit.SECONDS);
+            Assertions.assertEquals(2, mtmv.buildCount.get());
+            Assertions.assertTrue(cache.getOutputPredicates().isEmpty());
+            Assertions.assertSame(cache, 
mtmv.getOrGenerateCache(mtmv.context));
+        } finally {
+            mtmv.releaseFirstBuild.countDown();
+            executor.shutdownNow();
+            Assertions.assertTrue(executor.awaitTermination(5, 
TimeUnit.SECONDS));
+        }
+    }
+
+    @Test
+    void testGuaranteesRecoverOnlyAfterEveryPartitionUsesCurrentDefinition() {
+        CacheMTMV mtmv = new CacheMTMV();
+        mtmv.processBaseViewChange("base view predicate changed");
+        MTMVCache changedCache = mtmv.getOrGenerateCache(mtmv.context);
+        Assertions.assertTrue(changedCache.getOutputPredicates().isEmpty());
+
+        Env env = Mockito.mock(Env.class);
+        MTMVService service = Mockito.mock(MTMVService.class);
+        Mockito.when(env.getMtmvService()).thenReturn(service);
+
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            // Replay restores NORMAL after refreshing p1, but p2 still 
contains the old definition's rows.
+            Assertions.assertTrue(mtmv.addTaskResult(successfulRefresh(mtmv), 
mtmv.getRelation(),
+                    ImmutableMap.of("p1", new MTMVRefreshPartitionSnapshot()), 
true));
+            Assertions.assertEquals(MTMVState.NORMAL, 
mtmv.getStatus().getState());
+            Assertions.assertEquals(ImmutableSet.of("p1"), 
mtmv.getRefreshSnapshot().getPartitionSnapshots().keySet());
+            MTMVCache partiallyRefreshedCache = 
mtmv.getOrGenerateCache(mtmv.context);
+            Assertions.assertNotSame(changedCache, partiallyRefreshedCache);
+            
Assertions.assertTrue(partiallyRefreshedCache.getOutputPredicates().isEmpty());
+
+            // A live completion builds both caches before adding p2's 
snapshot. Publication must use
+            // the merged snapshot coverage and replace the previous cache 
without rebuilding on read.
+            Assertions.assertTrue(mtmv.addTaskResult(successfulRefresh(mtmv), 
mtmv.getRelation(),
+                    ImmutableMap.of("p2", new MTMVRefreshPartitionSnapshot()), 
false));
+            int buildsAtRefreshCompletion = mtmv.buildCount.get();
+            MTMVCache fullyRefreshedCache = 
mtmv.getOrGenerateCache(mtmv.context);
+            Assertions.assertNotSame(partiallyRefreshedCache, 
fullyRefreshedCache);
+            Assertions.assertEquals(ImmutableSet.of("p1", "p2"),
+                    
mtmv.getRefreshSnapshot().getPartitionSnapshots().keySet());
+            Assertions.assertEquals(ImmutableSet.of(mtmv.predicate), 
fullyRefreshedCache.getOutputPredicates());
+            Assertions.assertEquals(buildsAtRefreshCompletion, 
mtmv.buildCount.get());
+        }
+    }
+
+    private static MTMVTask successfulRefresh(CacheMTMV mtmv) {
+        MTMVTask task = new MTMVTask(mtmv, mtmv.getRelation(),
+                new MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+        task.setTaskId(1L);
+        task.setStatus(TaskStatus.SUCCESS);
+        Deencapsulation.setField(task, "mtmvSchemaChangeVersion", 
mtmv.getSchemaChangeVersion());
+        return task;
+    }
+
+    private static class CacheMTMV extends MTMV {
+        final ConnectContext context = Mockito.mock(ConnectContext.class);
+        final AtomicInteger buildCount = new AtomicInteger();
+        private final Plan plan = new LogicalOneRowRelation(new RelationId(1),
+                ImmutableList.of(new Alias(new ExprId(1), new 
IntegerLiteral(20), "id")));
+        private final Expression predicate = new 
GreaterThan(plan.getOutput().get(0), new IntegerLiteral(10));
+
+        CacheMTMV() {
+            Mockito.when(context.getSessionVariable()).thenReturn(new 
SessionVariable());
+            setId(1L);
+            Deencapsulation.setField(this, "name", "mv");
+            setQualifiedDbName("db");
+            setQuerySql("select id from base_view");
+            MTMVStatus status = new MTMVStatus(MTMVState.NORMAL, null);
+            status.setRefreshState(MTMVRefreshState.SUCCESS);
+            setStatus(status);
+            setRefreshInfo(new MTMVRefreshInfo(BuildMode.IMMEDIATE, 
RefreshMethod.COMPLETE,
+                    new MTMVRefreshTriggerInfo(RefreshTrigger.MANUAL)));
+            setJobInfo(new MTMVJobInfo("mv_job"));
+            setMvProperties(new HashMap<>());
+            setRelation(new MTMVRelation(ImmutableSet.of(), ImmutableSet.of(), 
ImmutableSet.of(),
+                    ImmutableSet.of(), ImmutableSet.of()));
+            setMvPartitionInfo(new MTMVPartitionInfo());
+            Deencapsulation.setField(this, "sessionVariables", new HashMap<>(
+                    
context.getSessionVariable().getAffectQueryResultInPlanVariables()));
+            setBaseIndexId(1L);
+            setIndexMeta(1L, "mv", ImmutableList.of(new Column("id", 
PrimitiveType.INT, true)),
+                    0, 0, (short) 1, TStorageType.COLUMN, KeysType.DUP_KEYS);
+            setPartitionInfo(new SinglePartitionInfo());
+            addPartition(new Partition(1L, "p1", new MaterializedIndex(), 
null));
+            addPartition(new Partition(2L, "p2", new MaterializedIndex(), 
null));
+            MTMVRefreshSnapshot snapshot = new MTMVRefreshSnapshot();
+            snapshot.updateSnapshots(ImmutableMap.of("p1", new 
MTMVRefreshPartitionSnapshot(),
+                    "p2", new MTMVRefreshPartitionSnapshot()), 
getPartitionNames());
+            setRefreshSnapshot(snapshot);
+        }
+
+        @Override
+        protected MTMVCache createRewriteCache(ConnectContext currentContext, 
boolean needLock,
+                boolean addSessionVarGuard) {
+            buildCount.incrementAndGet();
+            return new MTMVCache(Pair.of(plan, null), plan, null, 
ImmutableList.of(), ImmutableSet.of(predicate));
+        }
+    }
+
+    private static class BlockingCacheMTMV extends CacheMTMV {
+        private final CountDownLatch firstBuildStarted = new CountDownLatch(1);
+        private final CountDownLatch releaseFirstBuild = new CountDownLatch(1);
+
+        @Override
+        protected MTMVCache createRewriteCache(ConnectContext currentContext, 
boolean needLock,
+                boolean addSessionVarGuard) {
+            MTMVCache cache = super.createRewriteCache(currentContext, 
needLock, addSessionVarGuard);
+            if (buildCount.get() == 1) {
+                firstBuildStarted.countDown();
+                try {
+                    Assertions.assertTrue(releaseFirstBuild.await(5, 
TimeUnit.SECONDS));
+                } catch (InterruptedException exception) {
+                    Thread.currentThread().interrupt();
+                    throw new AssertionError(exception);
+                }
+            }
+            return cache;
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/MTMVCacheTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/MTMVCacheTest.java
index 5cb57665d59..2691c57daf1 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/MTMVCacheTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/MTMVCacheTest.java
@@ -19,29 +19,49 @@ package org.apache.doris.nereids.mv;
 
 import org.apache.doris.catalog.MTMV;
 import org.apache.doris.mtmv.MTMVCache;
+import org.apache.doris.mtmv.MTMVPlanUtil;
+import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVState;
+import org.apache.doris.mtmv.MTMVRefreshSnapshot;
 import org.apache.doris.mtmv.MTMVRelationManager;
+import org.apache.doris.mtmv.MTMVStatus;
 import org.apache.doris.nereids.CascadesContext;
 import 
org.apache.doris.nereids.rules.exploration.mv.AsyncMaterializationContext;
 import org.apache.doris.nereids.rules.exploration.mv.MaterializationContext;
 import org.apache.doris.nereids.sqltest.SqlTestBase;
+import org.apache.doris.nereids.trees.expressions.GreaterThan;
 import org.apache.doris.nereids.trees.expressions.SessionVarGuardExpr;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
 import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
 import org.apache.doris.nereids.util.PlanChecker;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.SessionVariable;
 import org.apache.doris.qe.SqlModeHelper;
 
+import com.google.common.collect.ImmutableList;
 import mockit.Mock;
 import mockit.MockUp;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.Mockito;
 
+import java.time.LocalDate;
+import java.time.ZoneOffset;
 import java.util.BitSet;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 
 /**
  * Relevant test case about mtmv cache.
@@ -129,4 +149,131 @@ public class MTMVCacheTest extends SqlTestBase {
                 .anyMatch(expr -> 
expr.containsType(SessionVarGuardExpr.class)));
         dropMvByNereids("drop materialized view mv1");
     }
+
+    @Test
+    void testInvalidateShouldNotPublishInFlightRewriteCache() throws Exception 
{
+        ControlledCacheMTMV mtmv = new ControlledCacheMTMV();
+        ExecutorService executor = Executors.newSingleThreadExecutor();
+        try {
+            Future<MTMVCache> cacheFuture = executor.submit(() -> 
mtmv.getOrGenerateCache(connectContext));
+            Assertions.assertTrue(mtmv.firstBuildStarted.await(5, 
TimeUnit.SECONDS));
+
+            mtmv.invalidateRewriteCache();
+            mtmv.releaseFirstBuild.countDown();
+
+            MTMVCache generatedCache = cacheFuture.get(5, TimeUnit.SECONDS);
+            Assertions.assertNotSame(mtmv.firstCache, generatedCache);
+            Assertions.assertSame(mtmv.secondCache, generatedCache);
+            Assertions.assertSame(generatedCache, 
mtmv.getOrGenerateCache(connectContext));
+        } finally {
+            executor.shutdownNow();
+        }
+    }
+
+    @Test
+    void testNondeterministicDefinitionDoesNotExportFoldedPredicates() throws 
Exception {
+        createFunction("create alias function mv_cache_day(int) with 
parameter(n) "
+                + "as dayofmonth(days_add(current_date(), n))");
+        try {
+            for (String expression : ImmutableList.of(
+                    "dayofmonth(current_date())", "dayofmonth(CURRENT_DATE)", 
"mv_cache_day(0)")) {
+                for (LocalDate date : ImmutableList.of(LocalDate.of(2026, 9, 
9), LocalDate.of(2026, 9, 10))) {
+                    ConnectContext cacheContext = 
Mockito.spy(MTMVPlanUtil.createBasicMvContext(connectContext,
+                            
MTMVPlanUtil.DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE,
+                            
connectContext.getSessionVariable().getAffectQueryResultInPlanVariables()));
+                    cacheContext.getSessionVariable().setTimeZone("UTC");
+                    
Mockito.doReturn(date.atStartOfDay(ZoneOffset.UTC).toInstant())
+                            .when(cacheContext).getStartTimeInstant();
+                    cacheContext.setThreadLocalInfo();
+                    MTMVCache cache = MTMVCache.from("select id, score from T1 
where score > " + expression,
+                            cacheContext, false, true, connectContext, false);
+                    LogicalFilter<? extends Plan> filter = 
cache.getOriginalFinalPlan()
+                            .<LogicalFilter<? extends 
Plan>>collectFirst(LogicalFilter.class::isInstance)
+                            .orElseThrow(AssertionError::new);
+                    // The same definition folds to a different bound when its 
cache is rebuilt tomorrow.
+                    
Assertions.assertTrue(filter.getConjuncts().stream().anyMatch(predicate ->
+                                    predicate instanceof GreaterThan && 
predicate.child(1) instanceof Literal
+                                            && ((Literal) 
predicate.child(1)).getStringValue()
+                                                    
.equals(Integer.toString(date.getDayOfMonth()))),
+                            cache.getOriginalFinalPlan()::treeString);
+                    
Assertions.assertTrue(cache.getOutputPredicates().isEmpty(), 
cache.getOutputPredicates()::toString);
+                }
+            }
+        } finally {
+            connectContext.setThreadLocalInfo();
+            executeNereidsSql("drop function mv_cache_day(int)");
+        }
+    }
+
+    @ParameterizedTest(name = "throughView={0}")
+    @ValueSource(booleans = {false, true})
+    void testReplacedAliasDefinitionDoesNotExportFoldedPredicates(boolean 
throughView) throws Exception {
+        createFunction("create alias function mv_cache_bound(int) with 
parameter(n) as abs(n)");
+        try {
+            String definitionSql = "select id from T1 where id > 
mv_cache_bound(0)";
+            if (throughView) {
+                // View analysis must retain the alias dependency in its 
parent's statement.
+                createView("create view mv_cache_alias_view as " + 
definitionSql);
+                definitionSql = "select id from mv_cache_alias_view";
+            }
+            for (int bound : ImmutableList.of(0, 10)) {
+                if (bound == 10) {
+                    executeNereidsSql("drop function mv_cache_bound(int)");
+                    createFunction("create alias function mv_cache_bound(int) 
with parameter(n) as abs(n + 10)");
+                }
+                ConnectContext cacheContext = 
MTMVPlanUtil.createBasicMvContext(connectContext,
+                        MTMVPlanUtil.DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE,
+                        
connectContext.getSessionVariable().getAffectQueryResultInPlanVariables());
+                cacheContext.setThreadLocalInfo();
+                MTMVCache cache = MTMVCache.from(definitionSql, cacheContext, 
false, true, connectContext, false);
+                LogicalFilter<? extends Plan> filter = 
cache.getOriginalFinalPlan()
+                        .<LogicalFilter<? extends 
Plan>>collectFirst(LogicalFilter.class::isInstance)
+                        .orElseThrow(AssertionError::new);
+                // A rebuild binds the new deterministic body, although stored 
MV rows have not been refreshed.
+                
Assertions.assertTrue(filter.getConjuncts().stream().anyMatch(predicate ->
+                                predicate instanceof GreaterThan && 
predicate.child(1) instanceof Literal
+                                        && ((Literal) 
predicate.child(1)).getStringValue()
+                                                
.equals(Integer.toString(bound))),
+                        cache.getOriginalFinalPlan()::treeString);
+                Assertions.assertTrue(cache.getOutputPredicates().isEmpty(), 
cache.getOutputPredicates()::toString);
+            }
+        } finally {
+            connectContext.setThreadLocalInfo();
+            if (throughView) {
+                dropView("drop view if exists mv_cache_alias_view");
+            }
+            executeNereidsSql("drop function if exists mv_cache_bound(int)");
+        }
+    }
+
+    private static class ControlledCacheMTMV extends MTMV {
+        private final CountDownLatch firstBuildStarted = new CountDownLatch(1);
+        private final CountDownLatch releaseFirstBuild = new CountDownLatch(1);
+        private final AtomicInteger buildCount = new AtomicInteger();
+        private final MTMVCache firstCache = new MTMVCache(
+                null, null, null, Collections.emptyList(), 
Collections.emptySet());
+        private final MTMVCache secondCache = new MTMVCache(
+                null, null, null, Collections.emptyList(), 
Collections.emptySet());
+
+        private ControlledCacheMTMV() {
+            setStatus(new MTMVStatus(MTMVState.NORMAL, null));
+            setRefreshSnapshot(new MTMVRefreshSnapshot());
+        }
+
+        @Override
+        protected MTMVCache createRewriteCache(ConnectContext currentContext, 
boolean needLock,
+                boolean addSessionVarGuard) {
+            if (buildCount.incrementAndGet() == 1) {
+                firstBuildStarted.countDown();
+                try {
+                    Assertions.assertTrue(releaseFirstBuild.await(5, 
TimeUnit.SECONDS));
+                } catch (InterruptedException e) {
+                    Thread.currentThread().interrupt();
+                    throw new RuntimeException(e);
+                }
+                return firstCache;
+            }
+            return secondCache;
+        }
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/RelationImpliedPredicateTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/RelationImpliedPredicateTest.java
new file mode 100644
index 00000000000..378fed01ec2
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/RelationImpliedPredicateTest.java
@@ -0,0 +1,627 @@
+// 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.
+
+package org.apache.doris.nereids.mv;
+
+import org.apache.doris.catalog.Database;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.catalog.Partition;
+import org.apache.doris.common.jmockit.Deencapsulation;
+import org.apache.doris.mtmv.BaseTableInfo;
+import org.apache.doris.mtmv.MTMVCache;
+import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVRefreshState;
+import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVState;
+import org.apache.doris.mtmv.MTMVRefreshPartitionSnapshot;
+import org.apache.doris.mtmv.MTMVRelationManager;
+import org.apache.doris.nereids.CascadesContext;
+import org.apache.doris.nereids.rules.exploration.mv.MaterializationContext;
+import 
org.apache.doris.nereids.rules.exploration.mv.MaterializedViewPredicateCollector;
+import 
org.apache.doris.nereids.rules.exploration.mv.PreMaterializedViewRewriter.PreRewriteStrategy;
+import org.apache.doris.nereids.rules.exploration.mv.StructInfo;
+import org.apache.doris.nereids.sqltest.SqlTestBase;
+import org.apache.doris.nereids.trees.copier.DeepCopierContext;
+import org.apache.doris.nereids.trees.copier.LogicalPlanDeepCopier;
+import org.apache.doris.nereids.trees.expressions.EqualTo;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.GreaterThan;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.literal.Literal;
+import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalFilter;
+import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalFilter;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalOlapScan;
+import org.apache.doris.nereids.trees.plans.physical.PhysicalPlan;
+import org.apache.doris.nereids.util.ExpressionUtils;
+import org.apache.doris.nereids.util.PlanChecker;
+import org.apache.doris.qe.ConnectContext;
+
+import com.google.common.collect.ImmutableList;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Tests derivation and use of predicates guaranteed by materialized view 
outputs.
+ */
+public class RelationImpliedPredicateTest extends SqlTestBase {
+
+    @Override
+    protected void runBeforeAll() throws Exception {
+        super.runBeforeAll();
+        MTMVRelationManager existingManager = 
Env.getCurrentEnv().getMtmvService().getRelationManager();
+        MTMVRelationManager spyManager = Mockito.spy(existingManager);
+        Mockito.doAnswer(invocation -> {
+            MTMV mtmv = invocation.getArgument(0);
+            ConnectContext ctx = invocation.getArgument(1);
+            ctx.getStatementContext().getMvCanRewritePartitionsMap()
+                    .putIfAbsent(new BaseTableInfo(mtmv), 
mtmv.getPartitions());
+            return true;
+        }).when(spyManager).isMVPartitionValid(Mockito.any(MTMV.class),
+                Mockito.any(ConnectContext.class),
+                Mockito.anyBoolean(), Mockito.any(Map.class));
+        Deencapsulation.setField(Env.getCurrentEnv().getMtmvService(), 
"relationManager", spyManager);
+        
Env.getCurrentEnv().getMtmvService().registerHook("MTMVRelationManager", 
spyManager);
+    }
+
+    @Test
+    void testOutputPredicatesBindToGeneratedDirectAndCopiedScans() throws 
Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_fact");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_fact "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1') \n"
+                    + "as select T1.id, T1.score, T1.score as score_copy from 
T1 where T1.score = 1;");
+            mockRefreshedMtmv("mv_relation_implied_fact");
+
+            CascadesContext context = createCascadesContext(
+                    "select T1.id, T1.score, T1.score as score_copy from T1 
where T1.score = 1",
+                    connectContext);
+            PlanChecker.from(context)
+                    .setIsQuery()
+                    .analyze()
+                    .rewrite();
+
+            Assertions.assertEquals(1, 
context.getMaterializationContexts().size());
+            MaterializationContext materializationContext = 
context.getMaterializationContexts().get(0);
+            Plan scanPlan = materializationContext.getScanPlan(null, context);
+            Assertions.assertNotNull(scanPlan);
+            LogicalOlapScan generatedScan = 
scanPlan.<LogicalOlapScan>collectFirst(LogicalOlapScan.class::isInstance)
+                    .orElseThrow(() -> new 
AssertionError(scanPlan.treeString()));
+
+            CascadesContext directContext = createCascadesContext("select * 
from mv_relation_implied_fact");
+            PlanChecker.from(directContext).setIsQuery().analyze();
+            LogicalOlapScan directScan = directContext.getRewritePlan()
+                    
.<LogicalOlapScan>collectFirst(LogicalOlapScan.class::isInstance).orElseThrow(AssertionError::new);
+            LogicalOlapScan copiedScan = (LogicalOlapScan) directScan.accept(
+                    LogicalPlanDeepCopier.INSTANCE, new DeepCopierContext());
+            
Assertions.assertTrue(directScan.getOutputSet().stream().noneMatch(copiedScan.getOutputSet()::contains));
+
+            // Duplicate definition expressions need one output 
representative, not a product of slot mappings.
+            for (LogicalOlapScan scan : ImmutableList.of(generatedScan, 
directScan, copiedScan)) {
+                Set<Expression> predicates = new 
MaterializedViewPredicateCollector(directContext).collect(scan);
+                Assertions.assertTrue(predicates.stream()
+                        .allMatch(predicate -> 
scan.getOutputSet().containsAll(predicate.getInputSlots())),
+                        predicates::toString);
+                Slot score = scan.getOutput().stream().filter(slot -> 
slot.getName().equals("score"))
+                        .findFirst().orElseThrow(AssertionError::new);
+                Assertions.assertTrue(predicates.stream().anyMatch(predicate 
-> predicate instanceof EqualTo
+                                && predicate.child(0).equals(score) && 
predicate.child(1) instanceof Literal
+                                && ((Literal) 
predicate.child(1)).getStringValue().equals("1")),
+                        predicates::toString);
+            }
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_fact");
+        }
+    }
+
+    @Test
+    void testNestedMvRewriteByOutputPredicate() throws Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_explicit_join");
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_fact_with_score");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_fact_with_score "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1') \n"
+                    + "as select T1.id, T1.score from T1 where T1.score = 1;");
+            mockRefreshedMtmv("mv_relation_implied_fact_with_score");
+
+            createMvByNereids("create materialized view 
mv_relation_implied_explicit_join "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1') \n"
+                    + "as select f.id, f.score as fact_score, T2.score as 
dim_score "
+                    + "from mv_relation_implied_fact_with_score f "
+                    + "inner join T2 on f.id = T2.id "
+                    + "where f.score = 1;");
+            mockRefreshedMtmv("mv_relation_implied_explicit_join");
+
+            CascadesContext context = createCascadesContext(
+                    "select T1.id, T1.score as fact_score, T2.score as 
dim_score "
+                            + "from T1 inner join T2 on T1.id = T2.id "
+                            + "where T1.score = 1",
+                    connectContext);
+            PhysicalPlan bestPlan = PlanChecker.from(context)
+                    .setIsQuery()
+                    .analyze()
+                    .rewrite()
+                    .preMvRewrite()
+                    .optimize()
+                    .getBestPlanTree();
+            String plan = bestPlan.treeString();
+            
Assertions.assertTrue(plan.contains("mv_relation_implied_explicit_join"),
+                    () -> plan + 
MaterializationContext.toSummaryString(context, bestPlan));
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_explicit_join");
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_fact_with_score");
+        }
+    }
+
+    @Test
+    void testNestedMvRewriteAfterPredicateColumnIsProjectedOut() throws 
Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_projected_upper");
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_projected_lower");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_projected_lower "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select id, score from T1 where score = 1;");
+            mockRefreshedMtmv("mv_relation_implied_projected_lower");
+            createMvByNereids("create materialized view 
mv_relation_implied_projected_upper "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select f.id, T2.score as dim_score "
+                    + "from mv_relation_implied_projected_lower f inner join 
T2 on f.id = T2.id "
+                    + "where f.score > 0;");
+            MTMV upper = 
mockRefreshedMtmv("mv_relation_implied_projected_upper");
+            StructInfo cachedDefinition = 
upper.getOrGenerateCache(connectContext)
+                    .getAllRulesRewrittenPlanAndStructInfo().value();
+            
Assertions.assertTrue(cachedDefinition.getTopPlan().anyMatch(LogicalFilter.class::isInstance));
+
+            CascadesContext context = createCascadesContext(
+                    "select T1.id, T2.score as dim_score from T1 inner join T2 
on T1.id = T2.id "
+                            + "where T1.score = 1");
+            PhysicalPlan bestPlan = 
PlanChecker.from(context).setIsQuery().analyze().rewrite()
+                    .preMvRewrite().optimize().getBestPlanTree();
+            Assertions.assertTrue(bestPlan.anyMatch(plan -> plan instanceof 
PhysicalOlapScan
+                            && ((PhysicalOlapScan) plan).getTable().getName()
+                                    
.equals("mv_relation_implied_projected_upper")),
+                    () -> bestPlan.treeString() + 
MaterializationContext.toSummaryString(context, bestPlan));
+            // The successful candidate uses a query-local proof; the stored 
definition keeps its filter.
+            
Assertions.assertTrue(cachedDefinition.getTopPlan().anyMatch(LogicalFilter.class::isInstance));
+            List<PhysicalFilter<? extends Plan>> filters = 
bestPlan.collectToList(PhysicalFilter.class::isInstance);
+            Assertions.assertTrue(filters.stream().allMatch(filter -> 
filter.getConjuncts().stream()
+                            .allMatch(predicate -> 
filter.child().getOutputSet()
+                                    .containsAll(predicate.getInputSlots()))),
+                    bestPlan::treeString);
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_projected_upper");
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_projected_lower");
+        }
+    }
+
+    @Test
+    void testStructInfoRetainsFilterAboveNullExtendedMvScan() throws Exception 
{
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_nullable_input");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_nullable_input "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select id, score from T1 where score = 1;");
+            mockRefreshedMtmv("mv_relation_implied_nullable_input");
+            CascadesContext context = createCascadesContext(
+                    "select f.id from T2 left join 
mv_relation_implied_nullable_input f on T2.id = f.id "
+                            + "where f.score = 1");
+            // Analyze only: rewriting would turn this null-rejecting LEFT 
JOIN into an INNER JOIN first.
+            PlanChecker.from(context).setIsQuery().analyze();
+            Plan analyzedPlan = context.getRewritePlan();
+            LogicalFilter<? extends Plan> filter = analyzedPlan
+                    .<LogicalFilter<? extends 
Plan>>collectFirst(LogicalFilter.class::isInstance)
+                    .orElseThrow(AssertionError::new);
+            StructInfo structInfo = StructInfo.of(analyzedPlan, 
context).withoutRedundantMvFilters(context);
+            
Assertions.assertTrue(structInfo.getPredicates().getPulledUpPredicates()
+                    .containsAll(filter.getConjuncts()), 
structInfo.getTopPlan()::treeString);
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_nullable_input");
+        }
+    }
+
+    @Test
+    void testNestedMvRewriteWithNonOutputDefinitionPredicate() throws 
Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_join");
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_fact");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_fact "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1') \n"
+                    + "as select T1.id from T1 where T1.score = 1;");
+            mockRefreshedMtmv("mv_relation_implied_fact");
+
+            createMvByNereids("create materialized view 
mv_relation_implied_join "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1') \n"
+                    + "as select f.id, T2.score as dim_score "
+                    + "from mv_relation_implied_fact f "
+                    + "inner join T2 on f.id = T2.id;");
+            mockRefreshedMtmv("mv_relation_implied_join");
+
+            // Both sides scan the same lower MV. Its identity guarantees 
score = 1 even though score is not output.
+            CascadesContext context = createCascadesContext(
+                    "select T1.id, T2.score as dim_score "
+                            + "from T1 inner join T2 on T1.id = T2.id "
+                            + "where T1.score = 1",
+                    connectContext);
+            PhysicalPlan bestPlan = PlanChecker.from(context)
+                    .setIsQuery()
+                    .analyze()
+                    .rewrite()
+                    .preMvRewrite()
+                    .optimize()
+                    .getBestPlanTree();
+            String plan = bestPlan.treeString();
+            Assertions.assertTrue(plan.contains("mv_relation_implied_join"),
+                    () -> plan + 
MaterializationContext.toSummaryString(context, bestPlan));
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_join");
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_fact");
+        }
+    }
+
+    @Test
+    void testNestedInnerJoinRewriteRetainsNullRejectFilter() throws Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_left_join");
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_positive_id");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_positive_id "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select id from T1 where id > 0;");
+            mockRefreshedMtmv("mv_relation_implied_positive_id");
+            createMvByNereids("create materialized view 
mv_relation_implied_left_join "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select T2.id as dim_id, f.id as fact_id "
+                    + "from T2 left join mv_relation_implied_positive_id f on 
T2.id = f.id;");
+            mockRefreshedMtmv("mv_relation_implied_left_join");
+
+            CascadesContext context = createCascadesContext(
+                    "select T2.id as dim_id, T1.id as fact_id "
+                            + "from T2 inner join T1 on T2.id = T1.id where 
T1.id > 0");
+            PhysicalPlan bestPlan = 
PlanChecker.from(context).setIsQuery().analyze().rewrite()
+                    .preMvRewrite().optimize().getBestPlanTree();
+            Optional<PhysicalOlapScan> mvScan = bestPlan.collectFirst(plan -> 
plan instanceof PhysicalOlapScan
+                    && ((PhysicalOlapScan) 
plan).getTable().getName().equals("mv_relation_implied_left_join"));
+            Assertions.assertTrue(mvScan.isPresent(),
+                    () -> bestPlan.treeString() + 
MaterializationContext.toSummaryString(context, bestPlan));
+            Slot factId = mvScan.get().getOutput().stream().filter(slot -> 
slot.getName().equals("fact_id"))
+                    .findFirst().orElseThrow(AssertionError::new);
+            List<PhysicalFilter<? extends Plan>> filters = 
bestPlan.collectToList(PhysicalFilter.class::isInstance);
+            Set<Expression> compensation = filters.stream().flatMap(filter -> 
filter.getConjuncts().stream())
+                    .collect(Collectors.toSet());
+            // A LEFT JOIN MV can contain (dim_id = 2, fact_id = NULL); its 
scan must not be used unfiltered.
+            
Assertions.assertTrue(ExpressionUtils.inferNotNullSlots(compensation, 
context).contains(factId),
+                    bestPlan::treeString);
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_left_join");
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_positive_id");
+        }
+    }
+
+    @Test
+    void testNestedInnerJoinRewriteDoesNotRequireUnprojectedJoinKey() throws 
Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_unprojected_join_key");
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_join_key");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_join_key "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select id from T1 where id > 0;");
+            mockRefreshedMtmv("mv_relation_implied_join_key");
+            createMvByNereids("create materialized view 
mv_relation_implied_unprojected_join_key "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select f.id as fact_id "
+                    + "from T2 left join mv_relation_implied_join_key f on 
T2.id = f.id;");
+            mockRefreshedMtmv("mv_relation_implied_unprojected_join_key");
+
+            CascadesContext context = createCascadesContext(
+                    "select T1.id as fact_id from T2 inner join T1 on T2.id = 
T1.id where T1.id > 0");
+            List<Plan> alternatives = 
PlanChecker.from(context).setIsQuery().analyze().rewrite()
+                    .preMvRewrite().optimize().getAllPlan();
+            List<Plan> rewrittenPlans = alternatives.stream().filter(plan -> 
plan.anyMatch(node ->
+                            node instanceof LogicalOlapScan && 
((LogicalOlapScan) node).getTable().getName()
+                                    
.equals("mv_relation_implied_unprojected_join_key")))
+                    .collect(Collectors.toList());
+            Assertions.assertFalse(rewrittenPlans.isEmpty(),
+                    () -> 
alternatives.stream().map(Plan::treeString).collect(Collectors.joining("\n")));
+            // HyperGraph already matched T2.id = f.id. Requiring it as a 
filter would need the absent T2.id output.
+            for (Plan rewrittenPlan : rewrittenPlans) {
+                LogicalOlapScan scan = 
rewrittenPlan.<LogicalOlapScan>collectFirst(node ->
+                        node instanceof LogicalOlapScan && ((LogicalOlapScan) 
node).getTable().getName()
+                                
.equals("mv_relation_implied_unprojected_join_key")).orElseThrow(AssertionError::new);
+                Slot factId = scan.getOutput().stream().filter(slot -> 
slot.getName().equals("fact_id"))
+                        .findFirst().orElseThrow(AssertionError::new);
+                List<LogicalFilter<? extends Plan>> filters =
+                        
rewrittenPlan.collectToList(LogicalFilter.class::isInstance);
+                Set<Expression> compensation = filters.stream().flatMap(filter 
-> filter.getConjuncts().stream())
+                        .collect(Collectors.toSet());
+                
Assertions.assertTrue(ExpressionUtils.inferNotNullSlots(compensation, 
context).contains(factId),
+                        rewrittenPlan::treeString);
+            }
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_unprojected_join_key");
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_join_key");
+        }
+    }
+
+    @Test
+    void testDirectLowerMvQueryUsesUpperMv() throws Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_direct_upper");
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_direct_lower");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_direct_lower "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select id from T1 where score = 1;");
+            mockRefreshedMtmv("mv_relation_implied_direct_lower");
+            createMvByNereids("create materialized view 
mv_relation_implied_direct_upper "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select id from mv_relation_implied_direct_lower;");
+            mockRefreshedMtmv("mv_relation_implied_direct_upper");
+
+            CascadesContext context = createCascadesContext("select id from 
mv_relation_implied_direct_lower");
+            List<Plan> alternatives = 
PlanChecker.from(context).setIsQuery().analyze().rewrite()
+                    .preMvRewrite().optimize().getAllPlan();
+            // Both one-table plans can have the same estimated cost; assert 
rewrite availability.
+            Assertions.assertTrue(alternatives.stream().anyMatch(plan -> 
plan.anyMatch(node ->
+                            node instanceof LogicalOlapScan && 
((LogicalOlapScan) node).getTable().getName()
+                                    
.equals("mv_relation_implied_direct_upper"))),
+                    () -> 
alternatives.stream().map(Plan::treeString).collect(Collectors.joining("\n")));
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_direct_upper");
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_direct_lower");
+        }
+    }
+
+    @Test
+    void testSelfJoinNonOutputPredicatesAreNotExported() throws Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_self_join");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_self_join "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select a.id from T1 a cross join T1 b where a.score 
= 1 and b.score = 2;");
+            mockRefreshedMtmv("mv_relation_implied_self_join");
+            CascadesContext context = createCascadesContext("select id from 
mv_relation_implied_self_join");
+            PlanChecker.from(context).setIsQuery().analyze();
+            LogicalOlapScan scan = context.getRewritePlan()
+                    
.<LogicalOlapScan>collectFirst(LogicalOlapScan.class::isInstance).orElseThrow(AssertionError::new);
+            // The predicates constrain different rows of T1 and say nothing 
about the MV's only output, id.
+            Set<Expression> predicates = ((MTMV) 
scan.getTable()).getOrGenerateCache(connectContext)
+                    .getOutputPredicates();
+            Assertions.assertTrue(predicates.isEmpty(), predicates::toString);
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_self_join");
+        }
+    }
+
+    @Test
+    void testTimeDependentMvDefinitionDoesNotRemoveQueryFilter() throws 
Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_current_date");
+        try {
+            createMvByNereids("create materialized view 
mv_relation_implied_current_date "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1', 
'enable_nondeterministic_function' = 'true')\n"
+                    + "as select id, score from T1 where score > 
dayofmonth(current_date());");
+            mockRefreshedMtmv("mv_relation_implied_current_date");
+            CascadesContext context = createCascadesContext(
+                    "select id from mv_relation_implied_current_date where 
score > dayofmonth(current_date())");
+            PlanChecker.from(context).setIsQuery().analyze().rewrite();
+            Plan queryPlan = context.getRewritePlan();
+            LogicalFilter<? extends Plan> filter = queryPlan
+                    .<LogicalFilter<? extends 
Plan>>collectFirst(LogicalFilter.class::isInstance)
+                    .orElseThrow(AssertionError::new);
+            StructInfo structInfo = StructInfo.of(queryPlan, 
context).withoutRedundantMvFilters(context);
+            // Stored rows were selected at refresh time. Re-evaluating the 
date while planning
+            // cannot prove that today's query filter is redundant on those 
rows.
+            
Assertions.assertTrue(structInfo.getPredicates().getPulledUpPredicates()
+                    .containsAll(filter.getConjuncts()), 
structInfo.getTopPlan()::treeString);
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_current_date");
+        }
+    }
+
+    @Test
+    void testChangedBaseViewDoesNotSupplyOutputGuarantees() throws Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_changed_view");
+        dropView("drop view if exists relation_implied_base_view");
+        try {
+            createView("create view relation_implied_base_view as select id 
from T1 where id > 0");
+            createMvByNereids("create materialized view 
mv_relation_implied_changed_view "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select id from relation_implied_base_view;");
+            MTMV mtmv = mockRefreshedMtmv("mv_relation_implied_changed_view");
+            MTMVCache originalCache = mtmv.getOrGenerateCache(connectContext);
+            
Assertions.assertFalse(originalCache.getOutputPredicates().isEmpty());
+
+            executeNereidsSql("alter view relation_implied_base_view as select 
id from T1 where id > 10");
+            Assertions.assertEquals(MTMVState.SCHEMA_CHANGE, 
mtmv.getStatus().getState());
+            MTMVCache changedCache = mtmv.getOrGenerateCache(connectContext);
+            Assertions.assertNotSame(originalCache, changedCache);
+            
Assertions.assertTrue(changedCache.getOutputPredicates().isEmpty());
+            
Assertions.assertFalse(originalCache.getOutputPredicates().isEmpty());
+            Assertions.assertNotNull(changedCache.getOriginalFinalPlan());
+
+            CascadesContext context = createCascadesContext(
+                    "select id from mv_relation_implied_changed_view where id 
> 10");
+            PlanChecker.from(context).setIsQuery().analyze().rewrite();
+            Plan queryPlan = context.getRewritePlan();
+            LogicalFilter<? extends Plan> filter = queryPlan
+                    .<LogicalFilter<? extends 
Plan>>collectFirst(LogicalFilter.class::isInstance)
+                    .orElseThrow(AssertionError::new);
+            StructInfo structInfo = StructInfo.of(queryPlan, 
context).withoutRedundantMvFilters(context);
+            
Assertions.assertTrue(structInfo.getPredicates().getPulledUpPredicates()
+                    .containsAll(filter.getConjuncts()), 
structInfo.getTopPlan()::treeString);
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_changed_view");
+            dropView("drop view if exists relation_implied_base_view");
+        }
+    }
+
+    @Test
+    void testRefreshedLowerMvDoesNotRemoveCompensationForStaleUpper() throws 
Exception {
+        initMvRewriteSession();
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_stored_upper");
+        dropMvByNereids("drop materialized view if exists 
mv_relation_implied_stored_lower");
+        dropView("drop view if exists relation_implied_refresh_view");
+        try {
+            createView("create view relation_implied_refresh_view as select id 
from T1 where id > 0");
+            createMvByNereids("create materialized view 
mv_relation_implied_stored_lower "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1')\n"
+                    + "as select id from relation_implied_refresh_view;");
+            MTMV lower = mockRefreshedMtmv("mv_relation_implied_stored_lower");
+            createMvByNereids("create materialized view 
mv_relation_implied_stored_upper "
+                    + "BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1', 'grace_period' = 
'31536000')\n"
+                    + "as select id from mv_relation_implied_stored_lower;");
+            MTMV upper = mockRefreshedMtmv("mv_relation_implied_stored_upper");
+
+            executeNereidsSql("alter view relation_implied_refresh_view as 
select id from T1 where id > 10");
+            Assertions.assertEquals(MTMVState.SCHEMA_CHANGE, 
lower.getStatus().getState());
+            // Only the lower MV is refreshed. The upper still contains rows 
from before the view changed.
+            lower.getStatus().setState(MTMVState.NORMAL);
+            mockRefreshedPartitions(lower);
+            
Assertions.assertFalse(lower.getOrGenerateCache(connectContext).getOutputPredicates().isEmpty());
+            Assertions.assertEquals(MTMVState.NORMAL, 
upper.getStatus().getState());
+            upper.invalidateRewriteCache();
+            
Assertions.assertTrue(upper.getOrGenerateCache(connectContext).getOutputPredicates().isEmpty());
+
+            CascadesContext context = createCascadesContext(
+                    "select id from mv_relation_implied_stored_upper where id 
> 10");
+            PlanChecker.from(context).setIsQuery().analyze().rewrite();
+            LogicalFilter<? extends Plan> filter = context.getRewritePlan()
+                    .<LogicalFilter<? extends 
Plan>>collectFirst(LogicalFilter.class::isInstance)
+                    .orElseThrow(AssertionError::new);
+            StructInfo structInfo = StructInfo.of(context.getRewritePlan(), 
context)
+                    .withoutRedundantMvFilters(context);
+            
Assertions.assertTrue(structInfo.getPredicates().getPulledUpPredicates()
+                    .containsAll(filter.getConjuncts()), 
structInfo.getTopPlan()::treeString);
+
+            // Let the real grace-period check admit the old upper MV, instead 
of the fixture's
+            // unconditional partition-valid mock. Its old rows must still 
satisfy the query filter.
+            MTMVRelationManager relationManager = 
Env.getCurrentEnv().getMtmvService().getRelationManager();
+            
Mockito.doCallRealMethod().when(relationManager).isMVPartitionValid(Mockito.eq(upper),
+                    Mockito.any(ConnectContext.class), Mockito.anyBoolean(), 
Mockito.anyMap());
+            CascadesContext lowerQueryContext = createCascadesContext(
+                    "select id from mv_relation_implied_stored_lower where id 
> 10");
+            List<Plan> alternatives = 
PlanChecker.from(lowerQueryContext).setIsQuery().analyze().rewrite()
+                    .preMvRewrite().optimize().getAllPlan();
+            List<Plan> upperPlans = alternatives.stream().filter(plan -> 
plan.anyMatch(node ->
+                            node instanceof LogicalOlapScan && 
((LogicalOlapScan) node).getTable().getName()
+                                    
.equals("mv_relation_implied_stored_upper")))
+                    .collect(Collectors.toList());
+            Assertions.assertFalse(upperPlans.isEmpty(),
+                    () -> 
alternatives.stream().map(Plan::treeString).collect(Collectors.joining("\n")));
+            for (Plan upperPlan : upperPlans) {
+                List<LogicalFilter<? extends Plan>> filters = 
upperPlan.collectToList(LogicalFilter.class::isInstance);
+                
Assertions.assertTrue(filters.stream().anyMatch(candidateFilter -> 
candidateFilter.getConjuncts().stream()
+                                .anyMatch(predicate -> predicate instanceof 
GreaterThan
+                                        && 
candidateFilter.child().getOutputSet()
+                                                
.containsAll(predicate.getInputSlots())
+                                        && predicate.child(1) instanceof 
Literal
+                                        && ((Literal) 
predicate.child(1)).getStringValue().equals("10"))),
+                        upperPlan::treeString);
+            }
+        } finally {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_stored_upper");
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_stored_lower");
+            dropView("drop view if exists relation_implied_refresh_view");
+        }
+    }
+
+    private MTMV mockRefreshedMtmv(String name) {
+        Database db = (Database) 
Env.getCurrentEnv().getInternalCatalog().getDbNullable(connectContext.getDatabase());
+        MTMV mtmv = (MTMV) db.getTableNullable(name);
+        mtmv.getStatus().setState(MTMVState.NORMAL);
+        mtmv.getStatus().setRefreshState(MTMVRefreshState.SUCCESS);
+        mockRefreshedPartitions(mtmv);
+        return mtmv;
+    }
+
+    private void mockRefreshedPartitions(MTMV mtmv) {
+        // Candidate validity alone does not model a completed refresh. 
Populate definition coverage
+        // and publish a data version so rewrite normalization does not prune 
the MV as empty.
+        Map<String, MTMVRefreshPartitionSnapshot> snapshots = new HashMap<>();
+        for (Partition partition : mtmv.getPartitions()) {
+            long refreshVersion = partition.getNextVersion();
+            partition.setNextVersion(refreshVersion + 1);
+            partition.updateVisibleVersion(refreshVersion);
+            snapshots.put(partition.getName(), new 
MTMVRefreshPartitionSnapshot());
+        }
+        mtmv.getRefreshSnapshot().updateSnapshots(snapshots, 
mtmv.getPartitionNames());
+        mtmv.invalidateRewriteCache();
+    }
+
+    private void initMvRewriteSession() {
+        
connectContext.getSessionVariable().setDisableNereidsRules("PRUNE_EMPTY_PARTITION");
+        connectContext.getState().setIsQuery(true);
+        connectContext.getSessionVariable().enableMaterializedViewRewrite = 
true;
+        connectContext.getSessionVariable().enableMaterializedViewNestRewrite 
= true;
+        
connectContext.getSessionVariable().setPreMaterializedViewRewriteStrategy(PreRewriteStrategy.NOT_IN_RBO.name());
+        
connectContext.getSessionVariable().materializedViewRewriteDurationThresholdMs 
= 1000000;
+    }
+}
diff --git 
a/regression-test/data/nereids_rules_p0/mv/nested/relation_implied_predicate_nested_mtmv.out
 
b/regression-test/data/nereids_rules_p0/mv/nested/relation_implied_predicate_nested_mtmv.out
new file mode 100644
index 00000000000..5fde8180fcd
--- /dev/null
+++ 
b/regression-test/data/nereids_rules_p0/mv/nested/relation_implied_predicate_nested_mtmv.out
@@ -0,0 +1,8 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !query_before --
+1      10      100     A
+3      20      300     B
+
+-- !query_after --
+1      10      100     A
+3      20      300     B
diff --git 
a/regression-test/suites/nereids_rules_p0/mv/nested/relation_implied_predicate_nested_mtmv.groovy
 
b/regression-test/suites/nereids_rules_p0/mv/nested/relation_implied_predicate_nested_mtmv.groovy
new file mode 100644
index 00000000000..61d29ebc5ea
--- /dev/null
+++ 
b/regression-test/suites/nereids_rules_p0/mv/nested/relation_implied_predicate_nested_mtmv.groovy
@@ -0,0 +1,120 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("relation_implied_predicate_nested_mtmv") {
+    String db = context.config.getDbNameByFile(context.file)
+    sql "use ${db}"
+    sql "SET enable_nereids_planner=true"
+    sql "SET enable_fallback_to_original_planner=false"
+    sql "SET enable_materialized_view_rewrite=true"
+    sql "SET enable_materialized_view_nest_rewrite=true"
+    sql "SET pre_materialized_view_rewrite_strategy=NOT_IN_RBO"
+
+    sql """DROP MATERIALIZED VIEW IF EXISTS rip_mv_join"""
+    sql """DROP MATERIALIZED VIEW IF EXISTS rip_mv_fact"""
+    sql """DROP VIEW IF EXISTS rip_fact_view"""
+    sql """DROP TABLE IF EXISTS rip_fact"""
+    sql """DROP TABLE IF EXISTS rip_dim"""
+
+    sql """
+    CREATE TABLE rip_fact (
+        k1 BIGINT,
+        dim_id BIGINT,
+        sku_type VARCHAR(8),
+        amount BIGINT
+    )
+    DUPLICATE KEY(k1)
+    DISTRIBUTED BY HASH(k1) BUCKETS 1
+    PROPERTIES ("replication_num" = "1")
+    """
+
+    sql """
+    CREATE TABLE rip_dim (
+        dim_id BIGINT,
+        tag VARCHAR(16)
+    )
+    DUPLICATE KEY(dim_id)
+    DISTRIBUTED BY HASH(dim_id) BUCKETS 1
+    PROPERTIES ("replication_num" = "1")
+    """
+
+    sql """
+    INSERT INTO rip_fact VALUES
+        (1, 10, '1', 100),
+        (2, 10, '2', 200),
+        (3, 20, '1', 300)
+    """
+
+    sql """
+    INSERT INTO rip_dim VALUES
+        (10, 'A'),
+        (20, 'B')
+    """
+
+    sql """ANALYZE TABLE rip_fact WITH SYNC"""
+    sql """ANALYZE TABLE rip_dim WITH SYNC"""
+    sql """ALTER TABLE rip_fact MODIFY COLUMN amount SET STATS 
('row_count'='3')"""
+    sql """ALTER TABLE rip_dim MODIFY COLUMN tag SET STATS ('row_count'='2')"""
+    sql """CREATE VIEW rip_fact_view AS SELECT * FROM rip_fact WHERE k1 > 0"""
+
+    def query = """
+        SELECT f.k1, f.dim_id, f.amount, d.tag
+        FROM rip_fact f
+        INNER JOIN rip_dim d ON f.dim_id = d.dim_id
+        WHERE f.sku_type = '1' AND f.k1 > 0
+        ORDER BY 1, 2, 3, 4
+    """
+
+    sql "SET enable_materialized_view_rewrite=false"
+    order_qt_query_before "${query}"
+
+    sql "SET enable_materialized_view_rewrite=true"
+    create_async_mv(db, "rip_mv_fact", """
+        SELECT k1, dim_id, amount, sku_type
+        FROM rip_fact_view
+        WHERE sku_type = '1'
+    """)
+    create_async_mv(db, "rip_mv_join", """
+        SELECT f.k1, f.dim_id, f.amount, d.tag
+        FROM rip_mv_fact f
+        INNER JOIN rip_dim d ON f.dim_id = d.dim_id
+        WHERE f.sku_type = '1'
+    """)
+
+    mv_rewrite_success(query, "rip_mv_join")
+    order_qt_query_after "${query}"
+
+    // Only the lower MV adopts the new view definition. The upper still 
stores k1 = 1 and 3.
+    sql """ALTER MATERIALIZED VIEW rip_mv_join SET ('grace_period' = 
'31536000')"""
+    sql """ALTER VIEW rip_fact_view AS SELECT * FROM rip_fact WHERE k1 > 2"""
+    sql """REFRESH MATERIALIZED VIEW rip_mv_fact COMPLETE"""
+    waitingMTMVTaskFinished(getJobName(db, "rip_mv_fact"))
+
+    def lowerMvQuery = """
+        SELECT f.k1, f.dim_id, f.amount, d.tag
+        FROM rip_mv_fact f
+        INNER JOIN rip_dim d ON f.dim_id = d.dim_id
+        WHERE f.k1 > 2
+        ORDER BY 1, 2, 3, 4
+    """
+    def staleUpperQuery = lowerMvQuery.replaceFirst("SELECT", "SELECT /*+ 
use_mv(rip_mv_join) */")
+    def currentLowerQuery = lowerMvQuery.replaceFirst("SELECT",
+            "SELECT /*+ SET_VAR(enable_materialized_view_rewrite=false) */")
+    // The explicit query filter must still reject k1 = 1 when grace_period 
permits the old upper MV.
+    mv_rewrite_success(staleUpperQuery, "rip_mv_join")
+    check_sqls_result_equal(staleUpperQuery, currentLowerQuery)
+}


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

Reply via email to