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

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


The following commit(s) were added to refs/heads/master by this push:
     new 9e60fab4db0 [fix](mtmv) Eliminate redundant filters in nested MV 
candidate definitions (#63639)
9e60fab4db0 is described below

commit 9e60fab4db00998d490d916c4bf304b15490c1b9
Author: foxtail463 <[email protected]>
AuthorDate: Wed Oct 21 12:03:50 2026 +0800

    [fix](mtmv) Eliminate redundant filters in nested MV candidate definitions 
(#63639)
    
    Nested MV rewriting can fail when an upper MV repeats a filter that is
    already guaranteed by its lower MV.
    
    For example:
    
    ```sql
    mv_fact_snapshot_v1:
    SELECT k1, dim_id, sku_type, amount
    FROM fact
    WHERE sku_type = '1';
    ```
    
    ```sql
    mv_detail_trade_joined_v1:
    SELECT t.k1, t.dim_id, t.sku_type, t.amount, d.tag
    FROM mv_fact_snapshot_v1 t
    JOIN dim d ON t.dim_id = d.dim_id
    WHERE t.sku_type = '1';
    ```
    
    For the query:
    
    ```sql
    SELECT f.k1, f.dim_id, f.sku_type, f.amount, d.tag
    FROM fact f
    JOIN dim d ON f.dim_id = d.dim_id
    WHERE f.sku_type = '1';
    ```
    
    The query can match mv_fact_snapshot_v1, but matching
    mv_detail_trade_joined_v1 may be rejected because of the repeated
    t.sku_type = '1' filter. However, mv_fact_snapshot_v1 already guarantees
    that every output row satisfies this predicate, so the upper-MV filter
    is redundant.
    
    ### Solution:
    
    Use reliable predicates guaranteed by the lower MV to prove redundant
    filters in upper-MV definitions during candidate matching. This allows
    nested rewriting to match mv_detail_trade_joined_v1 without requiring
    the upper MV to expose additional filter columns.
    
    Query predicates must still be preserved for compensation. Cached MV
    definitions remain unchanged. Output-predicate guarantees are withheld
    whenever the MV definition or refresh state cannot reliably establish
    them.
    
    ---------
    
    Co-authored-by: yangtao555 <[email protected]>
---
 .../main/java/org/apache/doris/catalog/MTMV.java   |  63 +-
 .../main/java/org/apache/doris/mtmv/MTMVCache.java |  36 +-
 .../org/apache/doris/nereids/StatementContext.java |  25 +-
 .../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       | 242 ++++++++
 .../test/java/org/apache/doris/mtmv/MTMVTest.java  |  18 +-
 .../org/apache/doris/nereids/mv/MTMVCacheTest.java | 103 +++-
 .../nereids/mv/RelationImpliedPredicateTest.java   | 659 +++++++++++++++++++++
 .../relation_implied_predicate_nested_mtmv.out     |   8 +
 .../relation_implied_predicate_nested_mtmv.groovy  | 120 ++++
 15 files changed, 1431 insertions(+), 48 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 9805be4e157..6572a0edca9 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
@@ -297,6 +297,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);
     }
 
@@ -426,7 +427,12 @@ public class MTMV extends OlapTable {
                     applyRefreshedEpochs(capturedEpochs);
                 }
             }
+            boolean publishCache = false;
             if (task.getStatus() == TaskStatus.SUCCESS) {
+                publishCache = needUpdateCache && cacheGeneration == 
rewriteCacheGeneration && !isDropped;
+                // A successful refresh can restore a changed definition, 
including after several
+                // partial refreshes. Discard caches and builds from the 
previous snapshot coverage.
+                invalidateRewriteCache();
                 this.status.setState(MTMVState.NORMAL);
                 this.status.setSchemaChangeDetail(null);
                 this.status.setRefreshState(MTMVRefreshState.SUCCESS);
@@ -437,21 +443,6 @@ public class MTMV extends OlapTable {
                         ivmInfo.setPlanSignature(refreshedIvmPlanSignature);
                     }
                 }
-                // The refresh publishes a new plan, so every cache built 
before this commit is stale.
-                // Bump before publishing so an in-flight build cannot pass 
its generation check later.
-                boolean publishCache = needUpdateCache && cacheGeneration == 
rewriteCacheGeneration && !isDropped;
-                rewriteCacheGeneration++;
-                if (needUpdateCache) {
-                    MTMVCacheManager manager = 
Env.getCurrentEnv().getMtmvCacheManager();
-                    if (publishCache && mtmvCacheWithGuard != null) {
-                        manager.put(this.id, true, mtmvCacheWithGuard);
-                    } else {
-                        manager.invalidate(this.id);
-                    }
-                    if (publishCache && mtmvCacheWithoutGuard != null) {
-                        manager.put(this.id, false, mtmvCacheWithoutGuard);
-                    }
-                }
             } else {
                 this.status.setRefreshState(MTMVRefreshState.FAIL);
             }
@@ -465,6 +456,17 @@ public class MTMV extends OlapTable {
                 snapshotsToWrite = 
snapshotsOfCleanPartitions(partitionSnapshots);
             }
             this.refreshSnapshot.updateSnapshots(snapshotsToWrite, 
getPartitionNames());
+            if (publishCache) {
+                // Publish only after the refreshed partitions have been 
recorded. A partial
+                // refresh may restore NORMAL while other partitions still use 
the old definition.
+                MTMVCacheManager manager = 
Env.getCurrentEnv().getMtmvCacheManager();
+                if (mtmvCacheWithGuard != null) {
+                    manager.put(this.id, true, 
cacheWithValidOutputPredicates(mtmvCacheWithGuard));
+                }
+                if (mtmvCacheWithoutGuard != null) {
+                    manager.put(this.id, false, 
cacheWithValidOutputPredicates(mtmvCacheWithoutGuard));
+                }
+            }
             Env.getCurrentEnv().getMtmvService()
                     .refreshComplete(this, relation, task);
             if (isReplay) {
@@ -726,11 +728,11 @@ public class MTMV extends OlapTable {
             MTMVCache cached;
             readMvLock();
             try {
+                cacheGeneration = rewriteCacheGeneration;
                 cached = manager.isEnabled() ? manager.getIfPresent(this.id, 
guarded) : null;
                 if (cached == null && statementContext != null) {
-                    cached = statementContext.getQueryLocalMtmvCache(this.id, 
guarded);
+                    cached = statementContext.getQueryLocalMtmvCache(this.id, 
guarded, cacheGeneration);
                 }
-                cacheGeneration = rewriteCacheGeneration;
             } finally {
                 readMvUnlock();
             }
@@ -744,23 +746,24 @@ public class MTMV extends OlapTable {
                     // Someone invalidated between our snapshot and now; drop 
the stale build and retry.
                     continue;
                 }
+                MTMVCache published = 
cacheWithValidOutputPredicates(generated);
                 if (manager.isEnabled()) {
                     MTMVCache existing = manager.getIfPresent(this.id, 
guarded);
                     if (existing != null) {
                         return existing;
                     }
                     if (!isDropped) {
-                        manager.put(this.id, guarded, generated);
+                        manager.put(this.id, guarded, published);
                     }
                 } else if (statementContext != null && !isDropped) {
                     // Global cache is disabled (maximumSize=0); keep one copy 
for this statement only.
-                    MTMVCache existing = 
statementContext.getQueryLocalMtmvCache(this.id, guarded);
+                    MTMVCache existing = 
statementContext.getQueryLocalMtmvCache(this.id, guarded, cacheGeneration);
                     if (existing != null) {
                         return existing;
                     }
-                    statementContext.putQueryLocalMtmvCache(this.id, guarded, 
generated);
+                    statementContext.putQueryLocalMtmvCache(this.id, guarded, 
cacheGeneration, published);
                 }
-                return generated;
+                return published;
             } finally {
                 readMvUnlock();
             }
@@ -1769,6 +1772,16 @@ public class MTMV extends OlapTable {
         this.mvRwLock.writeLock().unlock();
     }
 
+    private MTMVCache cacheWithValidOutputPredicates(MTMVCache cache) {
+        // A cache built from the current definition can prove facts about 
stored rows only after
+        // every partition has been refreshed. Callers hold the MV lock while 
publishing it.
+        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{");
@@ -1806,8 +1819,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()));
         }
     }
 
@@ -1840,8 +1853,8 @@ public class MTMV extends OlapTable {
         }
         if (refreshInfo != null && refreshInfo.getRefreshMethod() == null) {
             LOG.warn("MTMV {} has unknown refresh method, marking as schema 
change", name);
-            status.setState(MTMVState.SCHEMA_CHANGE);
-            status.setSchemaChangeDetail("Unknown refresh method detected 
during deserialization");
+            alterStatus(new MTMVStatus(MTMVState.SCHEMA_CHANGE,
+                    "Unknown refresh method detected during deserialization"));
         }
         Map<String, MTMVRefreshPartitionSnapshot> partitionSnapshots = 
refreshSnapshot.getPartitionSnapshots();
         compatiblePctSnapshot(partitionSnapshots);
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 8b168cac931..60f512abd4c 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.model.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/nereids/StatementContext.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/StatementContext.java
index 2913af5f004..4401e038d1a 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
@@ -166,6 +166,9 @@ public class StatementContext implements Closeable {
 
     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
@@ -339,8 +342,9 @@ public class StatementContext implements Closeable {
     private final Map<BaseTableInfo, Collection<Partition>> 
mvCanRewritePartitionsMap = new HashMap<>();
 
     // When the Env-wide MTMVCacheManager is disabled 
(mtmv_cache_manage_num=0), reuse rewrite plans
-    // in the same statement so multiple rewrite paths do not rebuild the same 
MV plan.
-    private final Map<Pair<Long, Boolean>, MTMVCache> queryLocalMtmvCaches = 
new HashMap<>();
+    // in the same statement so multiple rewrite paths do not rebuild the same 
MV plan. Each value
+    // carries the MV's cache generation because a planning retry can reuse 
this statement context.
+    private final Map<Pair<Long, Boolean>, Pair<Long, MTMVCache>> 
queryLocalMtmvCaches = new HashMap<>();
 
     /// for dictionary sink.
     private List<Backend> usedBackendsDistributing; // report used backends 
after done distribute planning.
@@ -628,6 +632,14 @@ public class StatementContext implements Closeable {
         return hasNondeterministic;
     }
 
+    public void recordAliasUdf() {
+        hasAliasUdf = true;
+    }
+
+    public boolean hasAliasUdf() {
+        return hasAliasUdf;
+    }
+
     public ConnectContext getConnectContext() {
         return connectContext;
     }
@@ -1677,12 +1689,13 @@ public class StatementContext implements Closeable {
         this.materializationRewrittenSuccessSet.add(materializationQualifier);
     }
 
-    public MTMVCache getQueryLocalMtmvCache(long mtmvId, boolean guarded) {
-        return queryLocalMtmvCaches.get(Pair.of(mtmvId, guarded));
+    public MTMVCache getQueryLocalMtmvCache(long mtmvId, boolean guarded, long 
generation) {
+        Pair<Long, MTMVCache> entry = queryLocalMtmvCaches.get(Pair.of(mtmvId, 
guarded));
+        return entry != null && entry.first == generation ? entry.second : 
null;
     }
 
-    public void putQueryLocalMtmvCache(long mtmvId, boolean guarded, MTMVCache 
cache) {
-        queryLocalMtmvCaches.put(Pair.of(mtmvId, guarded), cache);
+    public void putQueryLocalMtmvCache(long mtmvId, boolean guarded, long 
generation, MTMVCache cache) {
+        queryLocalMtmvCaches.put(Pair.of(mtmvId, guarded), Pair.of(generation, 
cache));
     }
 
     public Multimap<List<String>, Pair<RelationId, Set<String>>> 
getTableUsedPartitionNameMap() {
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 18e32ddedaa..48cb5050ef6 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
@@ -26,7 +26,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;
@@ -199,7 +198,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
@@ -620,14 +624,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 ac6c6879929..03c733c7c47 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
@@ -123,9 +123,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 e5109b5516c..41766a0cafc 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..c9d4202be36
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVOutputPredicatesTest.java
@@ -0,0 +1,242 @@
+// 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.catalog.info.TableNameInfo;
+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.mtmv.ivm.IvmInfo;
+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.persist.AlterMTMV;
+import org.apache.doris.persist.EditLog;
+import org.apache.doris.persist.EditLog.EditLogItem;
+import org.apache.doris.persist.OperationType;
+import org.apache.doris.qe.ConnectContext;
+import org.apache.doris.qe.SessionVariable;
+import org.apache.doris.thrift.TStorageType;
+
+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.List;
+import java.util.Map;
+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;
+import java.util.concurrent.atomic.AtomicLong;
+
+class MTMVOutputPredicatesTest {
+    private static final AtomicLong NEXT_MTMV_ID = new 
AtomicLong(1_000_000_000L);
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testDefinitionChangeInvalidatesPublishedGuarantees(boolean 
baseViewChange) {
+        CacheMTMV mtmv = new CacheMTMV();
+        MTMVCache beforeChange = mtmv.getOrGenerateCache(mtmv.context);
+        Assertions.assertEquals(Set.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);
+        MTMVCacheManager manager = new MTMVCacheManager();
+        MTMVService service = Mockito.mock(MTMVService.class);
+        EditLog editLog = Mockito.mock(EditLog.class);
+        EditLogItem editLogItem = Mockito.mock(EditLogItem.class);
+        Mockito.when(env.getMtmvService()).thenReturn(service);
+        Mockito.when(env.getMtmvCacheManager()).thenReturn(manager);
+        Mockito.when(env.getEditLog()).thenReturn(editLog);
+        
Mockito.when(editLog.submitEdit(Mockito.eq(OperationType.OP_ALTER_MTMV), 
Mockito.any(AlterMTMV.class)))
+                .thenReturn(editLogItem);
+
+        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, 
"p1"), true));
+            Assertions.assertEquals(MTMVState.NORMAL, 
mtmv.getStatus().getState());
+            Assertions.assertEquals(Set.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, 
"p2"), false));
+            int buildsAtRefreshCompletion = mtmv.buildCount.get();
+            MTMVCache fullyRefreshedCache = 
mtmv.getOrGenerateCache(mtmv.context);
+            Assertions.assertNotSame(partiallyRefreshedCache, 
fullyRefreshedCache);
+            Assertions.assertEquals(Set.of("p1", "p2"),
+                    
mtmv.getRefreshSnapshot().getPartitionSnapshots().keySet());
+            Assertions.assertEquals(Set.of(mtmv.predicate), 
fullyRefreshedCache.getOutputPredicates());
+            Assertions.assertEquals(buildsAtRefreshCompletion, 
mtmv.buildCount.get());
+        }
+        Mockito.verify(editLogItem).await();
+    }
+
+    private static AlterMTMV successfulRefresh(CacheMTMV mtmv, String 
partition) {
+        MTMVTask task = new MTMVTask(mtmv, mtmv.getRelation(),
+                new MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+        task.setTaskId(1L);
+        task.setStatus(TaskStatus.SUCCESS);
+        Deencapsulation.setField(task, "mtmvSchemaChangeVersion", 
mtmv.getSchemaChangeVersion());
+        AlterMTMV result = new AlterMTMV(new TableNameInfo("db", "mv"), 
MTMVAlterOpType.ADD_TASK);
+        result.setTask(task);
+        result.setRelation(mtmv.getRelation());
+        result.setPartitionSnapshots(Map.of(partition, new 
MTMVRefreshPartitionSnapshot()));
+        return result;
+    }
+
+    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),
+                List.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(NEXT_MTMV_ID.incrementAndGet());
+            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(Set.of(), Set.of(), Set.of(), 
Set.of(), Set.of()));
+            setMvPartitionInfo(new MTMVPartitionInfo());
+            Deencapsulation.setField(this, "ivmInfo", new IvmInfo());
+            Deencapsulation.setField(this, "sessionVariables", new HashMap<>(
+                    
context.getSessionVariable().getAffectQueryResultInPlanVariables()));
+            setBaseIndexId(1L);
+            setIndexMeta(1L, "mv", List.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(Map.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, List.of(), 
Set.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/mtmv/MTMVTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java
index 1636d09517f..62dd652f68f 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTest.java
@@ -408,6 +408,9 @@ public class MTMVTest {
         EditLog editLog = Mockito.mock(EditLog.class);
         EditLogItem editLogItem = Mockito.mock(EditLogItem.class);
         Mockito.when(env.getEditLog()).thenReturn(editLog);
+        // The invalidation path drops the rewrite cache under the MV lock, so 
the mocked env
+        // has to serve a real cache manager, otherwise invalidateRewriteCache 
hits a null one.
+        Mockito.when(env.getMtmvCacheManager()).thenReturn(new 
MTMVCacheManager());
         
Mockito.when(editLog.submitEdit(Mockito.eq(OperationType.OP_ALTER_MTMV), 
Mockito.any(AlterMTMV.class)))
                 .thenAnswer(invocation -> {
                     
Assertions.assertTrue(mvRwLock.isWriteLockedByCurrentThread());
@@ -553,7 +556,7 @@ public class MTMVTest {
     }
 
     @Test
-    public void testDisabledCacheReusesPlanWithinSameStatement() throws 
Exception {
+    public void testDisabledCacheReusesPlanOnlyWithinCurrentGeneration() 
throws Exception {
         int originalMaxSize = Config.mtmv_cache_manage_num;
         try {
             Config.mtmv_cache_manage_num = 0;
@@ -562,7 +565,9 @@ public class MTMVTest {
 
             HookedMTMV mtmv = buildHookedMTMV();
             MTMVCache plan = Mockito.mock(MTMVCache.class);
+            MTMVCache refreshedPlan = Mockito.mock(MTMVCache.class);
             mtmv.lazyCaches.add(plan);
+            mtmv.lazyCaches.add(refreshedPlan);
 
             ConnectContext context = mockConnectContext();
             StatementContext statementContext = new StatementContext();
@@ -575,11 +580,18 @@ public class MTMVTest {
                 MTMVCache second = mtmv.getOrGenerateCache(context);
                 Assertions.assertSame(plan, first);
                 Assertions.assertSame(first, second);
+                Assertions.assertEquals(1, mtmv.lazyBuildCount);
+
+                // INSERT may plan again with this same StatementContext after 
releasing its table locks.
+                // A refresh between attempts must invalidate the local copy 
as well as the global cache.
+                
Assertions.assertTrue(mtmv.addTaskResult(buildSuccessTaskResult(mtmv), false));
+                MTMVCache afterRefresh = mtmv.getOrGenerateCache(context);
+                Assertions.assertSame(refreshedPlan, afterRefresh);
+                Assertions.assertSame(afterRefresh, 
mtmv.getOrGenerateCache(context));
             }
 
-            Assertions.assertEquals(1, mtmv.lazyBuildCount);
+            Assertions.assertEquals(2, mtmv.lazyBuildCount);
             Assertions.assertNull(manager.getIfPresent(mtmv.getId(), false));
-            Assertions.assertSame(plan, 
statementContext.getQueryLocalMtmvCache(mtmv.getId(), false));
         } finally {
             Config.mtmv_cache_manage_num = originalMaxSize;
         }
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 08c54591192..1e43a1c5afa 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,20 +19,33 @@ 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.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.SqlModeHelper;
 
+import com.google.common.collect.ImmutableList;
 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.Collections;
 import java.util.List;
 import java.util.Optional;
@@ -42,6 +55,7 @@ import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
 
 /**
  * Relevant test case about mtmv cache.
@@ -143,12 +157,97 @@ public class MTMVCacheTest extends SqlTestBase {
         }
     }
 
+    @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 static final AtomicLong NEXT_MTMV_ID = new 
AtomicLong(2_000_000_000L);
         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());
-        private final MTMVCache secondCache = new MTMVCache(null, null, null, 
Collections.emptyList());
+        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() {
+            setId(NEXT_MTMV_ID.incrementAndGet());
+            setStatus(new MTMVStatus(MTMVState.NORMAL, null));
+            setRefreshSnapshot(new MTMVRefreshSnapshot());
+        }
 
         @Override
         protected MTMVCache createRewriteCache(ConnectContext currentContext, 
boolean needLock,
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..72c3b257b36
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/mv/RelationImpliedPredicateTest.java
@@ -0,0 +1,659 @@
+// 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.Column;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.MTMV;
+import org.apache.doris.common.Config;
+import org.apache.doris.mtmv.MTMVCache;
+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.SlotReference;
+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.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+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 {
+
+    @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");
+        }
+    }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    void testIvmOutputPredicatesSkipHiddenColumns(boolean showHiddenColumns) 
throws Exception {
+        initMvRewriteSession();
+        boolean originalEnableTableStream = Config.enable_table_stream;
+        boolean originalEnableFeatureBinlog = Config.enable_feature_binlog;
+        boolean originalShowHiddenColumns = 
connectContext.getSessionVariable().showHiddenColumns();
+        Config.enable_table_stream = true;
+        // master gates ROW-binlog table creation behind 
enable_feature_binlog; the IVM base table needs it.
+        Config.enable_feature_binlog = true;
+        try {
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_ivm");
+            dropTableWithSql("drop table if exists relation_implied_ivm_base");
+            createTable("create table relation_implied_ivm_base (id bigint, 
score bigint) "
+                    + "duplicate key(id) distributed by hash(id) buckets 1 "
+                    + "properties('replication_num' = '1', 'binlog.enable' = 
'true', 'binlog.format' = 'ROW')");
+            createMvByNereids("create materialized view 
mv_relation_implied_ivm "
+                    + "BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL\n"
+                    + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+                    + "PROPERTIES ('replication_num' = '1', 'ivm_force_mow' = 
'true')\n"
+                    + "as select id, score from relation_implied_ivm_base 
where score = 1;");
+            mockRefreshedMtmv("mv_relation_implied_ivm");
+            CascadesContext context = createCascadesContext("select id, score 
from mv_relation_implied_ivm");
+            PlanChecker.from(context).setIsQuery().analyze();
+            LogicalOlapScan scan = context.getRewritePlan()
+                    
.<LogicalOlapScan>collectFirst(LogicalOlapScan.class::isInstance).orElseThrow(AssertionError::new);
+            Assertions.assertTrue(scan.getOutput().stream().anyMatch(slot -> 
slot instanceof SlotReference
+                    && slot.getName().equals(Column.IVM_ROW_ID_COL) && 
!((SlotReference) slot).isVisible()));
+            Set<Slot> visibleOutput = scan.getOutput().stream()
+                    .filter(slot -> ((SlotReference) 
slot).isVisible()).collect(Collectors.toSet());
+            MTMVCache cache = ((MTMV) 
scan.getTable()).getOrGenerateCache(connectContext);
+            Assertions.assertFalse(cache.getOutputPredicates().isEmpty(),
+                    () -> cache.getOriginalFinalPlan().treeString() + "\n"
+                            + 
cache.getAllRulesRewrittenPlanAndStructInfo().key().treeString());
+            // Definition output binding always uses visible physical columns, 
regardless of
+            // whether this query's session asks to display hidden columns.
+            
connectContext.getSessionVariable().setShowHiddenColumns(showHiddenColumns);
+            Set<Expression> predicates = new 
MaterializedViewPredicateCollector(context).collect(scan);
+            Assertions.assertFalse(predicates.isEmpty());
+            Assertions.assertTrue(predicates.stream()
+                    .allMatch(predicate -> 
visibleOutput.containsAll(predicate.getInputSlots())), predicates::toString);
+            Slot score = visibleOutput.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 {
+            
connectContext.getSessionVariable().setShowHiddenColumns(originalShowHiddenColumns);
+            Config.enable_table_stream = originalEnableTableStream;
+            Config.enable_feature_binlog = originalEnableFeatureBinlog;
+            dropMvByNereids("drop materialized view if exists 
mv_relation_implied_ivm");
+            dropTableWithSql("drop table if exists relation_implied_ivm_base");
+        }
+    }
+
+    @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) {
+        MTMV mtmv = mockCandidateMtmv(name);
+        mockRefreshedPartitions(mtmv);
+        return mtmv;
+    }
+
+    private void mockRefreshedPartitions(MTMV mtmv) {
+        // Candidate validity alone does not model a completed refresh. 
Populate definition coverage
+        // for every fixture partition, as addTaskResult does after successful 
refreshes.
+        Map<String, MTMVRefreshPartitionSnapshot> snapshots = new HashMap<>();
+        for (String partitionName : mtmv.getPartitionNames()) {
+            snapshots.put(partitionName, new MTMVRefreshPartitionSnapshot());
+        }
+        mtmv.getRefreshSnapshot().updateSnapshots(snapshots, 
mtmv.getPartitionNames());
+        mtmv.invalidateRewriteCache();
+    }
+
+    private void initMvRewriteSession() {
+        
connectContext.getSessionVariable().setDisableNereidsRules("PRUNE_EMPTY_PARTITION");
+        installValidRelationManager();
+        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