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 55c6d5f59c4 [refactor](ivm) Route changelog and snapshot handling 
through SupportChangelog (#69435)
55c6d5f59c4 is described below

commit 55c6d5f59c4a86dbe50fc77ae9dea3d9e64943f6
Author: yujun <[email protected]>
AuthorDate: Thu Oct 22 21:50:12 2026 +0800

    [refactor](ivm) Route changelog and snapshot handling through 
SupportChangelog (#69435)
    
    IVM assumed every base scan was an OLAP scan: it read `hasChangelog()`
    and the key model directly, and built the pre-/post-refresh snapshots
    through OLAP-only methods. That blocks external-table scans
    (Iceberg/Hive) from participating, and keeps the snapshot entry split
    between `withPreSnapshot` and `withPostSnapshot`.
    
    This change introduces `SupportChangelog`, a scan-level capability that
    answers:
    
    - whether the changelog is enabled for this scan (`isChangelogEnabled`),
    - whether the changelog contains only appends (`isChangelogAppendOnly`),
    - how to build a snapshot (`withSnapshot(boolean preSnapshot,
    BaseTableStream stream)`).
    
    `LogicalOlapScan` implements it. `LogicalOlapTableStreamScan` rejects
    every method, because IVM never uses a stream scan as a base scan.
    `IvmUtil.validateChangelogEnabled`, `IvmNormalizeDupKeysMTMV` and the
    CREATE-time key-type judgment in `IvmNormalizeMTMV#judgeKeysType` now go
    through the interface. `rewriteSnapshot` moved from `IvmDeltaRewriter`
    to `IvmDeltaRewriteHelper`, and the two snapshot methods collapsed into
    the single `withSnapshot` entry.
---
 .../doris/mtmv/ivm/IvmDeltaRewriteHelper.java      | 29 ++++++++++++++
 .../apache/doris/mtmv/ivm/IvmDeltaRewriter.java    | 32 ---------------
 .../apache/doris/mtmv/ivm/IvmDupDeltaRewriter.java |  4 +-
 .../apache/doris/mtmv/ivm/IvmJoinDeltaHandler.java | 12 +++---
 .../doris/mtmv/ivm/IvmNormalizeDupKeysMTMV.java    | 10 ++---
 .../java/org/apache/doris/mtmv/ivm/IvmUtil.java    |  2 +-
 .../nereids/rules/analysis/IvmNormalizeMTMV.java   |  8 ++--
 .../trees/plans/logical/LogicalOlapScan.java       | 31 ++++++++++-----
 .../plans/logical/LogicalOlapTableStreamScan.java  | 35 +++++++---------
 .../trees/plans/logical/SupportChangelog.java      | 46 ++++++++++++++++++++++
 .../doris/mtmv/ivm/IvmDeltaRewriteHelperTest.java  | 17 ++++----
 .../apache/doris/mtmv/ivm/IvmDeltaTestBase.java    | 21 ++++++++++
 .../trees/plans/logical/LogicalOlapScanTest.java   |  7 ++--
 .../ivm/test_ivm_append_only_dup_mv_3.groovy       |  2 +-
 14 files changed, 163 insertions(+), 93 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriteHelper.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriteHelper.java
index 9aae4a5b13b..2a7d09c90a0 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriteHelper.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriteHelper.java
@@ -44,6 +44,7 @@ import 
org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
 import org.apache.doris.nereids.trees.plans.logical.LogicalSubQueryAlias;
 import org.apache.doris.nereids.trees.plans.logical.LogicalUnion;
+import org.apache.doris.nereids.trees.plans.logical.SupportChangelog;
 import org.apache.doris.nereids.types.DataType;
 
 import com.google.common.collect.ImmutableList;
@@ -421,6 +422,34 @@ public class IvmDeltaRewriteHelper {
         return new LogicalProject<>(projects.build(), union);
     }
 
+    Pair<Plan, Map<Slot, Slot>> preSnapshot(Plan plan, IvmDeltaRewriteState 
rewriteState) {
+        return rewriteSnapshot(plan, rewriteState, true);
+    }
+
+    Pair<Plan, Map<Slot, Slot>> postSnapshot(Plan plan, IvmDeltaRewriteState 
rewriteState) {
+        return rewriteSnapshot(plan, rewriteState, false);
+    }
+
+    private Pair<Plan, Map<Slot, Slot>> rewriteSnapshot(Plan plan, 
IvmDeltaRewriteState rewriteState,
+            boolean preSnapshot) {
+        Plan rewritten = plan.rewriteDownShortCircuit(node -> {
+            // IVM reads only changelog-capable scans as base scans. A stream 
scan is such a scan only
+            // through its supertype, and an IVM query never contains one, so 
leave it untouched.
+            if (!(node instanceof SupportChangelog) || node instanceof 
LogicalOlapTableStreamScan) {
+                return node;
+            }
+            LogicalOlapScan scan = (LogicalOlapScan) node;
+            if (rewriteState.isExcluded(scan)) {
+                // Excluded tables produce no delta, but their snapshot side 
still joins
+                // the delta side; apply the window so the property also saves 
their scan.
+                return rewriteState.restrictWindow(scan);
+            }
+            scan = rewriteState.restrictWindow(scan);
+            return remapOlapScanToPlan(scan, scan.withSnapshot(preSnapshot, 
rewriteState.getStream(scan)));
+        });
+        return freshPlan(rewritten);
+    }
+
     /**
      * Deep copy a plan before reusing it in another branch, and return the 
copied output mapping.
      */
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
index 092b4635b9f..cef49bbd1ce 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
@@ -25,15 +25,12 @@ import org.apache.doris.common.Pair;
 import org.apache.doris.nereids.trees.expressions.Slot;
 import org.apache.doris.nereids.trees.plans.Plan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalEmptyRelation;
-import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
-import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
 import org.apache.doris.qe.ConnectContext;
 
 import com.google.common.base.Preconditions;
 
 import java.util.List;
-import java.util.Map;
 import java.util.Optional;
 
 /**
@@ -138,33 +135,4 @@ public class IvmDeltaRewriter {
         return false;
     }
 
-    static Pair<Plan, Map<Slot, Slot>> preSnapshot(Plan plan, 
IvmDeltaRewriteState rewriteState) {
-        return rewriteSnapshot(plan, rewriteState, true);
-    }
-
-    static Pair<Plan, Map<Slot, Slot>> postSnapshot(Plan plan, 
IvmDeltaRewriteState rewriteState) {
-        return rewriteSnapshot(plan, rewriteState, false);
-    }
-
-    private static Pair<Plan, Map<Slot, Slot>> rewriteSnapshot(Plan plan, 
IvmDeltaRewriteState rewriteState,
-            boolean preSnapshot) {
-        Plan rewritten = plan.rewriteDownShortCircuit(node -> {
-            if (!(node instanceof LogicalOlapScan)) {
-                return node;
-            }
-            LogicalOlapScan scan = (LogicalOlapScan) node;
-            if (rewriteState.isExcluded(scan)) {
-                // Excluded tables produce no delta, but their snapshot side 
still joins
-                // the delta side; apply the window so the property also saves 
their scan.
-                return rewriteState.restrictWindow(scan);
-            }
-            scan = rewriteState.restrictWindow(scan);
-            LogicalPlan snapshotScan = preSnapshot
-                    ? 
scan.withPreSnapshot(Optional.of(rewriteState.getStream(scan)))
-                    : scan.withPostSnapshot();
-            return IvmDeltaRewriteHelper.INSTANCE.remapOlapScanToPlan(scan, 
snapshotScan);
-        });
-        return IvmDeltaRewriteHelper.INSTANCE.freshPlan(rewritten);
-    }
-
 }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDupDeltaRewriter.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDupDeltaRewriter.java
index a3f10826086..f06eea226ef 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDupDeltaRewriter.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDupDeltaRewriter.java
@@ -165,14 +165,14 @@ class IvmDupDeltaRewriter extends 
PlanVisitor<Optional<Plan>, Void> {
             // The left delta joins the right as it was before the window, so 
it matches the rows both
             // sides already agreed on.
             contributions.add(rewriteInnerJoinContribution(join, true, 
leftDelta.get(),
-                    IvmDeltaRewriter.preSnapshot(join.right(), rewriteState)));
+                    helper.preSnapshot(join.right(), rewriteState)));
         }
         Optional<Plan> rightDelta = join.right().accept(this, context);
         if (rightDelta.isPresent()) {
             // The right delta joins the left as it is after the window, which 
is the side carrying the
             // rows the left appended in the same window.
             contributions.add(rewriteInnerJoinContribution(join, false, 
rightDelta.get(),
-                    IvmDeltaRewriter.postSnapshot(join.left(), rewriteState)));
+                    helper.postSnapshot(join.left(), rewriteState)));
         }
         return helper.combineDeltaPlans(contributions, join.getOutput());
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmJoinDeltaHandler.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmJoinDeltaHandler.java
index 3b6219f0c4a..e32ca6da718 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmJoinDeltaHandler.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmJoinDeltaHandler.java
@@ -118,18 +118,18 @@ class IvmJoinDeltaHandler {
         if (join.getJoinType().isOuterJoin()) {
             SideInput leftInput = deltaOnLeft
                     ? sideInput(join.left(), delta.plan)
-                    : sideInput(join.left(), 
IvmDeltaRewriter.postSnapshot(join.left(), rewriteState));
+                    : sideInput(join.left(), helper.postSnapshot(join.left(), 
rewriteState));
             SideInput rightInput = deltaOnLeft
-                    ? sideInput(join.right(), 
IvmDeltaRewriter.preSnapshot(join.right(), rewriteState))
+                    ? sideInput(join.right(), helper.preSnapshot(join.right(), 
rewriteState))
                     : sideInput(join.right(), delta.plan);
             return rewriteOuterContribution(join, deltaOnLeft, delta, 
leftInput, rightInput, rewriteState);
         } else if (deltaOnLeft) {
-            Pair<Plan, Map<Slot, Slot>> rightPreSnapshot = 
IvmDeltaRewriter.preSnapshot(join.right(), rewriteState);
+            Pair<Plan, Map<Slot, Slot>> rightPreSnapshot = 
helper.preSnapshot(join.right(), rewriteState);
             return rewriteInnerJoinContribution(join, delta.plan,
                     helper.mapBusinessOutputs(join.left().getOutput(), 
delta.plan.getOutput()),
                     rightPreSnapshot.first, rightPreSnapshot.second, delta);
         } else {
-            Pair<Plan, Map<Slot, Slot>> leftPostSnapshot = 
IvmDeltaRewriter.postSnapshot(join.left(), rewriteState);
+            Pair<Plan, Map<Slot, Slot>> leftPostSnapshot = 
helper.postSnapshot(join.left(), rewriteState);
             return rewriteInnerJoinContribution(join, leftPostSnapshot.first,
                     helper.mapBusinessOutputs(join.left().getOutput(), 
leftPostSnapshot.first.getOutput()), delta.plan,
                     helper.mapBusinessOutputs(join.right().getOutput(), 
delta.plan.getOutput()), delta);
@@ -146,8 +146,8 @@ class IvmJoinDeltaHandler {
         }
         Plan deltaSideChild = deltaOnLeft ? join.left() : join.right();
         return rewriteNullSideDelta(new NullSideDeltaContext(join, 
deltaOnLeft, delta, leftInput, rightInput,
-                IvmDeltaRewriter.preSnapshot(deltaSideChild, rewriteState),
-                IvmDeltaRewriter.postSnapshot(deltaSideChild, rewriteState),
+                helper.preSnapshot(deltaSideChild, rewriteState),
+                helper.postSnapshot(deltaSideChild, rewriteState),
                 rewriteState, delta.maxDeltaIndex));
     }
 
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmNormalizeDupKeysMTMV.java
 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmNormalizeDupKeysMTMV.java
index 781880a07b8..f16e8fe1a34 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmNormalizeDupKeysMTMV.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmNormalizeDupKeysMTMV.java
@@ -17,8 +17,6 @@
 
 package org.apache.doris.mtmv.ivm;
 
-import org.apache.doris.catalog.KeysType;
-import org.apache.doris.catalog.OlapTable;
 import org.apache.doris.catalog.info.TableNameInfo;
 import org.apache.doris.nereids.exceptions.AnalysisException;
 import org.apache.doris.nereids.trees.plans.Plan;
@@ -77,15 +75,15 @@ public class IvmNormalizeDupKeysMTMV extends 
DefaultPlanRewriter<Set<TableNameIn
 
     @Override
     public Plan visitLogicalOlapScan(LogicalOlapScan scan, Set<TableNameInfo> 
excludedTriggerTables) {
-        OlapTable table = scan.getTable();
-        if (table.getKeysType() != KeysType.DUP_KEYS) {
+        if (!scan.isChangelogAppendOnly()) {
             // An AnalysisException rather than an IvmException: a refresh 
turns every IvmException into a
             // fallback, which would express "the premise of this MV is 
broken" as "try a complete refresh",
             // even though that refresh runs this same normalize and fails the 
same way. The premise being
             // broken is not a retry.
             throw new AnalysisException(
-                    "IVM detail MV error: table " + table.getName() + " is " + 
table.getKeysType()
-                            + ", but a detail MV can only be built over 
duplicate-key detail tables");
+                    "IVM detail MV error: table " + scan.getTable().getName()
+                            + " does not provide an append-only changelog, but 
a detail MV can only be "
+                            + "built over scans whose changelog contains only 
appends");
         }
         IvmUtil.validateChangelogEnabled(excludedTriggerTables, scan);
         return scan;
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmUtil.java 
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmUtil.java
index 5ff1d9357b5..575fd2cd0a4 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmUtil.java
@@ -382,7 +382,7 @@ public class IvmUtil {
             return;
         }
         OlapTable table = scan.getTable();
-        if (!table.hasChangelog()) {
+        if (!scan.isChangelogEnabled()) {
             throw new IvmException(IvmFailureReason.CHANGELOG_NOT_ENABLED,
                     "SQL can be incrementally refreshed, but changelog is not 
enabled for table: "
                             + table.getName()
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/IvmNormalizeMTMV.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/IvmNormalizeMTMV.java
index 8c0aac09430..fe194e0b89c 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/IvmNormalizeMTMV.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/rules/analysis/IvmNormalizeMTMV.java
@@ -29,8 +29,8 @@ import org.apache.doris.nereids.jobs.JobContext;
 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.LogicalJoin;
-import org.apache.doris.nereids.trees.plans.logical.LogicalOlapScan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalRepeat;
+import org.apache.doris.nereids.trees.plans.logical.SupportChangelog;
 import org.apache.doris.nereids.trees.plans.visitor.CustomRewriter;
 
 import com.google.common.base.Preconditions;
@@ -151,9 +151,9 @@ public class IvmNormalizeMTMV implements CustomRewriter {
         if (needsRowIdMaintenance) {
             return KeysType.UNIQUE_KEYS;
         }
-        List<LogicalOlapScan> scans = 
plan.collectToList(LogicalOlapScan.class::isInstance);
-        for (LogicalOlapScan scan : scans) {
-            if (scan.getTable().getKeysType() != KeysType.DUP_KEYS) {
+        List<SupportChangelog> scans = 
plan.collectToList(SupportChangelog.class::isInstance);
+        for (SupportChangelog scan : scans) {
+            if (!scan.isChangelogAppendOnly()) {
                 return KeysType.UNIQUE_KEYS;
             }
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScan.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScan.java
index 3008fccdc61..97f446d0519 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScan.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScan.java
@@ -23,6 +23,7 @@ import org.apache.doris.catalog.KeysType;
 import org.apache.doris.catalog.MTMV;
 import org.apache.doris.catalog.OlapTable;
 import org.apache.doris.catalog.Table;
+import org.apache.doris.catalog.stream.BaseTableStream;
 import org.apache.doris.catalog.stream.OlapTableStream;
 import org.apache.doris.catalog.stream.OlapTableStreamWrapper;
 import org.apache.doris.catalog.stream.StreamReadMode;
@@ -84,7 +85,8 @@ import java.util.stream.Collectors;
 /**
  * Logical OlapScan.
  */
-public class LogicalOlapScan extends LogicalCatalogRelation implements 
OlapScan, SupportPruneNestedColumn {
+public class LogicalOlapScan extends LogicalCatalogRelation
+        implements OlapScan, SupportPruneNestedColumn, SupportChangelog {
 
     private static final Logger LOG = 
LogManager.getLogger(LogicalOlapScan.class);
 
@@ -961,10 +963,25 @@ public class LogicalOlapScan extends 
LogicalCatalogRelation implements OlapScan,
         return scoreRangeInfo;
     }
 
-    /** Build a pre-refresh snapshot as a stream scan in snapshot mode. */
-    public LogicalPlan withPreSnapshot(Optional<OlapTableStream> stream) {
+    @Override
+    public boolean isChangelogEnabled() {
+        return getTable().hasChangelog();
+    }
+
+    @Override
+    public boolean isChangelogAppendOnly() {
+        return getTable().getKeysType() == KeysType.DUP_KEYS;
+    }
+
+    @Override
+    public LogicalPlan withSnapshot(boolean preSnapshot, BaseTableStream 
stream) {
+        if (!preSnapshot) {
+            return LogicalPlanDeepCopier.INSTANCE.deepCopy(
+                    (LogicalPlan) this, new DeepCopierContext());
+        }
+        OlapTableStream olapStream = (OlapTableStream) stream;
         OlapTableStreamWrapper streamWrapper = new OlapTableStreamWrapper(
-                stream.get(), getTable(), selectedPartitionIds);
+                olapStream, getTable(), selectedPartitionIds);
         return new LogicalOlapTableStreamScan(
                 StatementScopeIdGenerator.newRelationId(),
                 streamWrapper,
@@ -977,12 +994,6 @@ public class LogicalOlapScan extends 
LogicalCatalogRelation implements OlapScan,
         ).withReadMode(StreamReadMode.SNAPSHOT);
     }
 
-    /** Build a post-refresh snapshot as a regular olap scan. */
-    public LogicalPlan withPostSnapshot() {
-        return LogicalPlanDeepCopier.INSTANCE.deepCopy(
-                (LogicalPlan) this, new DeepCopierContext());
-    }
-
     protected List<SlotReference> createSlotsVectorized(List<Column> columns) {
         List<String> qualified = qualified();
         SlotReference[] slots = new SlotReference[columns.size()];
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapTableStreamScan.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapTableStreamScan.java
index 6ab3587740c..b0535ea2020 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapTableStreamScan.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapTableStreamScan.java
@@ -23,14 +23,14 @@ import org.apache.doris.catalog.KeysType;
 import org.apache.doris.catalog.OlapTable;
 import org.apache.doris.catalog.Table;
 import org.apache.doris.catalog.constraint.TableIdentifier;
-import org.apache.doris.catalog.stream.OlapTableStream;
+import org.apache.doris.catalog.stream.BaseTableStream;
 import org.apache.doris.catalog.stream.OlapTableStreamWrapper;
 import org.apache.doris.catalog.stream.StreamReadMode;
 import org.apache.doris.common.IdGenerator;
+import org.apache.doris.nereids.exceptions.AnalysisException;
 import org.apache.doris.nereids.memo.GroupExpression;
 import org.apache.doris.nereids.properties.LogicalProperties;
 import org.apache.doris.nereids.properties.OrderKey;
-import org.apache.doris.nereids.rules.analysis.BindRelation;
 import org.apache.doris.nereids.trees.TableSample;
 import org.apache.doris.nereids.trees.expressions.ExprId;
 import org.apache.doris.nereids.trees.expressions.NamedExpression;
@@ -547,27 +547,22 @@ public class LogicalOlapTableStreamScan extends 
LogicalOlapScan {
     }
 
     @Override
-    public LogicalPlan withPreSnapshot(Optional<OlapTableStream> stream) {
-        return withReadMode(StreamReadMode.SNAPSHOT);
+    public boolean isChangelogEnabled() {
+        throw unsupportedChangelogScan();
     }
 
     @Override
-    public LogicalPlan withPostSnapshot() {
-        OlapTable baseTable = getTable().getBaseTable();
-        LogicalOlapScan scan = new LogicalOlapScan(
-                StatementScopeIdGenerator.newRelationId(),
-                baseTable,
-                qualifier,
-                ImmutableList.of(),
-                baseTable.getPartitionIds(),
-                baseTable.getBaseIndexId(),
-                PreAggStatus.unset(),
-                ImmutableList.of(),
-                ImmutableList.of(),
-                Optional.empty(),
-                ImmutableList.of());
-        return BindRelation.checkAndAddDeleteSignFilter(
-                scan, ConnectContext.get(), baseTable);
+    public boolean isChangelogAppendOnly() {
+        throw unsupportedChangelogScan();
+    }
+
+    @Override
+    public LogicalPlan withSnapshot(boolean preSnapshot, BaseTableStream 
stream) {
+        throw unsupportedChangelogScan();
+    }
+
+    private static AnalysisException unsupportedChangelogScan() {
+        return new AnalysisException("IVM does not support 
LogicalOlapTableStreamScan as a base scan");
     }
 
     @Override
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/SupportChangelog.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/SupportChangelog.java
new file mode 100644
index 00000000000..f048687348b
--- /dev/null
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/logical/SupportChangelog.java
@@ -0,0 +1,46 @@
+// 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.trees.plans.logical;
+
+import org.apache.doris.catalog.stream.BaseTableStream;
+
+/**
+ * A scan that can expose a changelog and answer its shape.
+ *
+ * <p>Implementations answer whether the changelog is enabled and whether the 
changelog this scan
+ * reads contains only appends, and build the pre- and post-refresh snapshots 
an incremental
+ * refresh reads. It says nothing about the plan a refresh runs: an {@link 
LogicalOlapTableStreamScan}
+ * inherits this interface only through {@link LogicalOlapScan}, and it 
rejects every method because
+ * a stream scan is never a base scan of an IVM query.
+ */
+public interface SupportChangelog {
+    /** Whether the changelog this scan reads is enabled on its source. */
+    boolean isChangelogEnabled();
+
+    /**
+     * True when the changelog this scan reads contains only appends: no 
DELETE or UPDATE_BEFORE,
+     * so every change carries dml_factor = +1.
+     */
+    boolean isChangelogAppendOnly();
+
+    /**
+     * Build a snapshot of this scan from {@code stream}: the pre-refresh 
snapshot when
+     * {@code preSnapshot} is true, the post-refresh snapshot otherwise.
+     */
+    LogicalPlan withSnapshot(boolean preSnapshot, BaseTableStream stream);
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriteHelperTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriteHelperTest.java
index 876a451b4ed..c661dfbb5d6 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriteHelperTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriteHelperTest.java
@@ -29,6 +29,7 @@ import org.apache.doris.catalog.ScalarType;
 import org.apache.doris.catalog.Type;
 import org.apache.doris.catalog.stream.OlapTableStream;
 import org.apache.doris.catalog.stream.OlapTableStreamWrapper;
+import org.apache.doris.catalog.stream.StreamReadMode;
 import org.apache.doris.nereids.exceptions.AnalysisException;
 import org.apache.doris.nereids.trees.expressions.Alias;
 import org.apache.doris.nereids.trees.expressions.Expression;
@@ -70,7 +71,7 @@ class IvmDeltaRewriteHelperTest extends IvmDeltaTestBase {
 
     @Test
     void testIsIncrementalDeltaScan_streamScanNotIncremental_returnsFalse() {
-        LogicalOlapTableStreamScan scan = (LogicalOlapTableStreamScan) 
buildDeltaScan().withPreSnapshot(Optional.empty());
+        LogicalOlapTableStreamScan scan = 
buildDeltaScan().withReadMode(StreamReadMode.SNAPSHOT);
         Assertions.assertFalse(helper.isIncrementalDeltaScan(scan));
     }
 
@@ -161,7 +162,7 @@ class IvmDeltaRewriteHelperTest extends IvmDeltaTestBase {
                     .getDbOrAnalysisException("test_db")
                     .getTableOrAnalysisException(IvmUtil.streamName(0L, 
scan.getTable().getFullQualifiers()));
 
-            LogicalPlan preSnapshot = (LogicalPlan) 
scan.withPreSnapshot(Optional.of(stream));
+            LogicalPlan preSnapshot = scan.withSnapshot(true, stream);
             LogicalPlan remapped = helper.remapOlapScanToPlan(scan, 
preSnapshot);
 
             Assertions.assertInstanceOf(LogicalProject.class, remapped);
@@ -183,7 +184,7 @@ class IvmDeltaRewriteHelperTest extends IvmDeltaTestBase {
     void testRemapScanOutputForPostSnapshotPreservesExprId() {
         LogicalOlapScan scan = buildScanForTable(2, "t_post");
 
-        LogicalPlan postSnapshot = (LogicalPlan) scan.withPostSnapshot();
+        LogicalPlan postSnapshot = scan.withSnapshot(false, 
getRegisteredStream(scan.getTable(), 0L));
         LogicalPlan remapped = helper.remapOlapScanToPlan(scan, postSnapshot);
 
         Assertions.assertInstanceOf(LogicalProject.class, remapped);
@@ -211,7 +212,7 @@ class IvmDeltaRewriteHelperTest extends IvmDeltaTestBase {
         LogicalProject<LogicalOlapTableStreamScan> oldProject = new 
LogicalProject<>(
                 ImmutableList.of(aliasId, passthroughName), deltaScan);
 
-        LogicalPlan newChild = (LogicalPlan) 
deltaScan.withPreSnapshot(Optional.empty());
+        LogicalPlan newChild = deltaScan.withReadMode(StreamReadMode.SNAPSHOT);
         LogicalProject<?> remapped = helper.remapStreamScanToPlan(oldProject, 
newChild);
 
         Assertions.assertInstanceOf(LogicalOlapTableStreamScan.class, 
remapped.child());
@@ -229,7 +230,7 @@ class IvmDeltaRewriteHelperTest extends IvmDeltaTestBase {
         LogicalProject<LogicalOlapTableStreamScan> oldProject = new 
LogicalProject<>(
                 ImmutableList.of(aliasId, passthroughName), deltaScan);
 
-        LogicalPlan newChild = (LogicalPlan) deltaScan.withPostSnapshot();
+        LogicalPlan newChild = buildPostSnapshotScan(deltaScan);
         LogicalProject<?> remapped = helper.remapStreamScanToPlan(oldProject, 
newChild);
 
         Assertions.assertInstanceOf(LogicalOlapScan.class, remapped.child());
@@ -245,7 +246,7 @@ class IvmDeltaRewriteHelperTest extends IvmDeltaTestBase {
         LogicalProject<LogicalOlapTableStreamScan> oldProject = new 
LogicalProject<>(
                 ImmutableList.of(missingStreamColumn), deltaScan);
 
-        LogicalPlan newChild = (LogicalPlan) deltaScan.withPostSnapshot();
+        LogicalPlan newChild = buildPostSnapshotScan(deltaScan);
         LogicalProject<?> remapped = helper.remapStreamScanToPlan(oldProject, 
newChild);
         Assertions.assertInstanceOf(Alias.class, 
remapped.getProjects().get(0));
         Assertions.assertInstanceOf(NullLiteral.class,
@@ -259,7 +260,7 @@ class IvmDeltaRewriteHelperTest extends IvmDeltaTestBase {
         LogicalProject<LogicalOlapTableStreamScan> oldProject = new 
LogicalProject<>(
                 ImmutableList.of(hiddenAlias), deltaScan);
 
-        LogicalPlan newChild = (LogicalPlan) deltaScan.withPostSnapshot();
+        LogicalPlan newChild = buildPostSnapshotScan(deltaScan);
         LogicalProject<?> remapped = helper.remapStreamScanToPlan(oldProject, 
newChild);
         Assertions.assertInstanceOf(Alias.class, 
remapped.getProjects().get(0));
         Assertions.assertInstanceOf(NullLiteral.class, ((Alias) 
remapped.getProjects().get(0)).child());
@@ -286,7 +287,7 @@ class IvmDeltaRewriteHelperTest extends IvmDeltaTestBase {
             Assertions.assertEquals(new BigIntLiteral(0L),
                     findAliasChild(streamProject, Column.SEQUENCE_COL));
 
-            LogicalPlan snapshotPlan = (LogicalPlan) 
streamScan.withPostSnapshot();
+            LogicalPlan snapshotPlan = buildPostSnapshotScan(streamScan);
             LogicalProject<?> remapped = 
helper.remapStreamScanToPlan(streamProject, snapshotPlan);
 
             
Assertions.assertEquals(helper.findSlotByName(snapshotPlan.getOutput(), 
Column.DELETE_SIGN),
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java 
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
index e0c7b676c25..7ac4e359b7a 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
@@ -53,6 +53,7 @@ import org.apache.doris.nereids.StatementContext;
 import org.apache.doris.nereids.analyzer.UnboundTableSink;
 import org.apache.doris.nereids.jobs.JobContext;
 import org.apache.doris.nereids.properties.PhysicalProperties;
+import org.apache.doris.nereids.rules.analysis.BindRelation;
 import org.apache.doris.nereids.trees.expressions.Add;
 import org.apache.doris.nereids.trees.expressions.Alias;
 import org.apache.doris.nereids.trees.expressions.Expression;
@@ -65,11 +66,13 @@ import 
org.apache.doris.nereids.trees.expressions.functions.agg.Max;
 import org.apache.doris.nereids.trees.expressions.functions.agg.Min;
 import org.apache.doris.nereids.trees.expressions.functions.agg.Sum;
 import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.PreAggStatus;
 import 
org.apache.doris.nereids.trees.plans.commands.insert.InsertIntoTableCommand;
 import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
 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.logical.LogicalOlapTableStreamScan;
+import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
 import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
 import org.apache.doris.nereids.trees.plans.logical.LogicalResultSink;
 import org.apache.doris.nereids.util.MemoTestUtils;
@@ -145,6 +148,24 @@ abstract class IvmDeltaTestBase {
                 ImmutableList.of("test_db"));
     }
 
+    /** Builds the regular post-refresh snapshot scan of a delta scan's base 
table. */
+    protected LogicalPlan buildPostSnapshotScan(LogicalOlapTableStreamScan 
streamScan) {
+        OlapTable baseTable = streamScan.getTable().getBaseTable();
+        LogicalOlapScan scan = new LogicalOlapScan(
+                PlanConstructor.getNextRelationId(),
+                baseTable,
+                ImmutableList.of("test_db"),
+                ImmutableList.of(),
+                baseTable.getPartitionIds(),
+                baseTable.getBaseIndexId(),
+                PreAggStatus.unset(),
+                ImmutableList.of(),
+                ImmutableList.of(),
+                Optional.empty(),
+                ImmutableList.of());
+        return BindRelation.checkAndAddDeleteSignFilter(scan, 
ConnectContext.get(), baseTable);
+    }
+
     private OlapTableStreamWrapper buildStreamWrapper(OlapTable baseTable) {
         OlapTableStream stream = registerTestStream(baseTable, 1L);
         return new OlapTableStreamWrapper(stream, baseTable, 
ImmutableList.of());
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScanTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScanTest.java
index 0195f136968..9959dc0dd55 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScanTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/trees/plans/logical/LogicalOlapScanTest.java
@@ -226,7 +226,7 @@ public class LogicalOlapScanTest {
         LogicalOlapScan scan = createMockScan(ImmutableList.of());
         OlapTableStream stream = Mockito.mock(OlapTableStream.class);
 
-        LogicalPlan snapshot = scan.withPreSnapshot(Optional.of(stream));
+        LogicalPlan snapshot = scan.withSnapshot(true, stream);
         Assertions.assertInstanceOf(LogicalOlapTableStreamScan.class, 
snapshot);
         LogicalOlapTableStreamScan streamScan = (LogicalOlapTableStreamScan) 
snapshot;
         Assertions.assertTrue(streamScan.isSnapshot());
@@ -237,7 +237,8 @@ public class LogicalOlapScanTest {
     @Test
     public void testWithPostSnapshotReturnsRegularScan() {
         LogicalOlapScan scan1 = createMockScan(ImmutableList.of());
-        LogicalPlan snapshot = scan1.withPostSnapshot();
+        OlapTableStream stream = Mockito.mock(OlapTableStream.class);
+        LogicalPlan snapshot = scan1.withSnapshot(false, stream);
 
         Assertions.assertInstanceOf(LogicalOlapScan.class, snapshot);
         Assertions.assertFalse(snapshot instanceof LogicalOlapTableStreamScan);
@@ -255,7 +256,7 @@ public class LogicalOlapScanTest {
 
         OlapTableStream stream = Mockito.mock(OlapTableStream.class);
         LogicalOlapTableStreamScan streamScan = (LogicalOlapTableStreamScan) 
scan
-                .withPreSnapshot(Optional.of(stream));
+                .withSnapshot(true, stream);
         streamScan = streamScan.withSelectedPartitionIds(ImmutableList.of(), 
true);
 
         LogicalOlapTableStreamScan copiedStreamScan = 
streamScan.withSelectedPartitionIds(ImmutableList.of());
diff --git 
a/regression-test/suites/mtmv_p0/ivm/test_ivm_append_only_dup_mv_3.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_append_only_dup_mv_3.groovy
index 88c31ad7651..cd94249647e 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_append_only_dup_mv_3.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_append_only_dup_mv_3.groovy
@@ -311,7 +311,7 @@ suite("test_ivm_append_only_dup_mv_3") {
 
     test {
         sql """REFRESH MATERIALIZED VIEW dup_mv_premise INCREMENTAL WITH DRY 
RUN"""
-        exception "can only be built over duplicate-key detail tables"
+        exception "does not provide an append-only changelog"
     }
 
     // A rebuild would normalize the same way and fail the same way, so the MV 
is left in the state that


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

Reply via email to