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 01efbca40a5 [fix](ivm) Fall back to complete refresh when the IVM
stream is unusable (#68170)
01efbca40a5 is described below
commit 01efbca40a548fc266c5c387c38645e39dc0061d
Author: yujun <[email protected]>
AuthorDate: Tue Sep 22 17:18:47 2026 +0800
[fix](ivm) Fall back to complete refresh when the IVM stream is unusable
(#68170)
When an IVM incremental refresh hits a missing or
unusable base table stream, the rewrite fails with `STREAM_UNSUPPORTED`.
Previously this reason fell back to the partition-based refresh, which
reads the same broken stream again and fails the task, so the refresh
never reached the COMPLETE attempt whose reconcile step recreates the
stream. Only an explicit `REFRESH COMPLETE` could recover.
Two changes:
1. Add `STREAM_UNSUPPORTED` to
`IvmFailureReason.requiresCompleteRefresh()`, so an unusable stream
jumps directly to the COMPLETE attempt that reconciles it.
2. Move `reconcileIvmStreams` from the shared partition-based path into
`executeCompleteAttempt`. Behavior is unchanged (COMPLETE was its only
triggering mode), but it makes the complete attempt the single place
that resets stream baselines, which a stream's global baseline requires.
IVM incremental refresh now recovers automatically from an unusable base
table stream by falling back to a complete refresh instead of failing
the task.
---
.../apache/doris/job/extensions/mtmv/MTMVTask.java | 244 ++++++++++++---
.../apache/doris/mtmv/ivm/IvmFailureReason.java | 1 +
.../java/org/apache/doris/mtmv/MTMVTaskTest.java | 330 ++++++++++++++++-----
.../doris/mtmv/ivm/IvmFailureReasonTest.java | 3 +
.../mtmv_p0/ivm/test_ivm_chained_stream_scope.out | 6 +
...est_ivm_partitions_fallback_stream_unusable.out | 13 +
.../ivm/test_ivm_chained_stream_scope.groovy | 162 ++++++++++
..._ivm_partitions_fallback_stream_unusable.groovy | 187 ++++++++++++
8 files changed, 822 insertions(+), 124 deletions(-)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
index 8b24e11fe31..f0445240602 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/job/extensions/mtmv/MTMVTask.java
@@ -492,6 +492,24 @@ public class MTMVTask extends AbstractTask {
default:
throw new IllegalStateException("Unsupported refresh mode: " +
request.refreshMode);
}
+ // A base table the plan scans without a usable stream makes the
incremental attempt fail: its
+ // rewrite reads the stream of every table it scans. Only COMPLETE
reconciles streams, so a request
+ // that would try the incremental path first goes there directly, and
the attempt that cannot
+ // succeed -- with the baseline barrier it writes before it starts --
stays out of the way.
+ //
+ // Only that attempt is judged here. A partition refresh reads a
narrower set, and how narrow
+ // depends on the partitions it plans: a PCT table that no refreshed
partition's mapping names
+ // keeps its place in the plan as an ordinary scan, and an excluded
trigger table is never read
+ // through a stream. It checks its own scope once it has planned (see
+ // hasUnusableIvmStreamForPartitions), so judging it here by the whole
plan would send a partition
+ // refresh that would have worked to COMPLETE.
+ if (request.allowFallback && mtmv.isIvm() &&
attempts.contains(RefreshAttemptType.IVM)
+ && hasUnusableIvmStream()) {
+ ivmFallbackReason = IvmFailureReason.STREAM_UNSUPPORTED.name();
+ LOG.warn("IVM stream is unusable, mv={}, taskId={}. Continuing
with COMPLETE refresh.",
+ mtmv.getName(), getTaskId());
+ return Lists.newArrayList(RefreshAttemptType.COMPLETE);
+ }
return attempts;
}
@@ -549,6 +567,21 @@ public class MTMVTask extends AbstractTask {
}
}
+ /**
+ * Makes the barrier that says "these MV partitions must be rebuilt before
their IVM offsets may be
+ * used again" durable. Every caller writes it as soon as it has decided
the partition set and
+ * before anything that touches MV data or base table streams, so that a
crash or a rejection can
+ * only ever leave a barrier with no rebuild behind it, which merely costs
one extra rebuild, and
+ * never a rebuild with no barrier, which silently loses rows.
+ */
+ private void writeIvmBaselineBarrier(RefreshMode refreshMode) throws
JobException {
+ if (mtmv.isIvm()) {
+ // Persist the guard before the first baseline data transaction.
+ mtmv.persistIvmBaselineGuard(refreshMode,
Sets.newHashSet(needRefreshPartitions),
+ mtmvSchemaChangeVersion);
+ }
+ }
+
private void executeCompleteAttempt(MTMVRefreshContext context,
ConnectContext ctx)
throws JobException, AnalysisException {
this.needRefreshPartitions =
Lists.newArrayList(mtmv.getPartitionNames());
@@ -556,6 +589,16 @@ public class MTMVTask extends AbstractTask {
if (refreshMode == MTMVTaskRefreshMode.NOT_REFRESH) {
return;
}
+ // The barrier goes first: a stream this rebuild reconciles carries
the base table's current
+ // rows as its initial snapshot, and a later incremental refresh that
consumed it as a delta
+ // against data still built from the old baseline would double-count
them.
+ writeIvmBaselineBarrier(RefreshMode.COMPLETE);
+ // A complete rebuild resets the stream baselines, so reconcile
missing or unusable streams
+ // before reading anything. Only COMPLETE may do this: a stream
baseline is global, resetting
+ // it during a partial refresh would corrupt the partitions that
refresh does not touch.
+ if (mtmv.isIvm()) {
+ reconcileIvmStreams(ctx);
+ }
executePartitionBasedRefresh(context, RefreshMode.COMPLETE, ctx);
}
@@ -604,8 +647,32 @@ public class MTMVTask extends AbstractTask {
mtmv.getName(), getTaskId());
} else {
baselinePartitions.sort(String::compareTo);
+ // This rebuild reads the streams of the partitions it rebuilds,
exactly like any other
+ // partition refresh, so it judges them before it commits to the
rebuild. A request that may
+ // not fall back fails instead of rebuilding less than it asked
for; one that may reaches the
+ // COMPLETE attempt, which is also the only attempt that
reconciles the stream this rebuild
+ // cannot read. Judging it here rather than in buildAttempts
matters for a request whose
+ // attempt list holds no IVM attempt -- PARTITIONS FALLBACK is
exactly that -- because the
+ // pre-step runs before the attempts do.
+ if (mtmv.isIvm()
+ && hasUnusableIvmStreamForPartitions(context,
baselinePartitions)) {
+ if (!request.allowFallback) {
+ throw new JobException("IVM stream is unusable for the
partitions of this refresh, mv="
+ + mtmv.getName());
+ }
+ ivmFallbackReason = IvmFailureReason.STREAM_UNSUPPORTED.name();
+ LOG.warn("IVM stream is unusable for the partitions this
baseline rebuild plans, mv={}, "
+ + "taskId={}. Continuing with COMPLETE refresh.",
mtmv.getName(), getTaskId());
+ attempts.clear();
+ attempts.add(RefreshAttemptType.COMPLETE);
+ return;
+ }
this.needRefreshPartitions = baselinePartitions;
this.refreshMode = generateRefreshMode(baselinePartitions);
+ writeIvmBaselineBarrier(RefreshMode.PARTITIONS);
+ // Anything else that fails here is reported as it is -- leaving
the barrier behind would
+ // make the IVM attempt that follows reject the task with
"baseline rebuild is pending"
+ // instead of the real reason.
executePartitionBasedRefresh(context, RefreshMode.PARTITIONS, ctx);
}
mtmv.releaseIvmBaselineRebuild(mtmvSchemaChangeVersion);
@@ -776,9 +843,26 @@ public class MTMVTask extends AbstractTask {
}
this.needRefreshPartitions = partitionPlan.partitions;
this.refreshMode = generateRefreshMode(needRefreshPartitions);
+ // This attempt now knows which partitions it refreshes, and with them
which streams it reads.
+ // Judged here rather than in buildAttempts because only the plan
knows that scope, and judged
+ // before the NOT_REFRESH return below so that a fallback-capable
request still reaches the only
+ // attempt that reconciles streams. Falling back here continues to
COMPLETE, which is what repairs
+ // them; a request that may not fall back fails instead of quietly
refreshing less than it asked.
+ if (mtmv.isIvm()
+ && hasUnusableIvmStreamForPartitions(partitionPlan.context,
needRefreshPartitions)) {
+ if (!request.allowFallback) {
+ throw new JobException("IVM stream is unusable for the
partitions of this refresh, mv="
+ + mtmv.getName());
+ }
+ ivmFallbackReason = IvmFailureReason.STREAM_UNSUPPORTED.name();
+ LOG.warn("IVM stream is unusable for the partitions this refresh
plans, mv={}, taskId={}. "
+ + "Continuing with COMPLETE refresh.", mtmv.getName(),
getTaskId());
+ return false;
+ }
if (refreshMode == MTMVTaskRefreshMode.NOT_REFRESH) {
return true;
}
+ writeIvmBaselineBarrier(RefreshMode.PARTITIONS);
executePartitionBasedRefresh(partitionPlan.context,
RefreshMode.PARTITIONS, ctx);
return true;
}
@@ -788,14 +872,6 @@ public class MTMVTask extends AbstractTask {
throws JobException, AnalysisException {
boolean useIvmFallbackStreams = mtmv.isIvm();
Map<TableIf, String> tableWithPartKey = getIncrementalTableMap();
- if (useIvmFallbackStreams) {
- // Persist the guard before the first baseline data transaction.
- mtmv.persistIvmBaselineGuard(refreshMode,
Sets.newHashSet(needRefreshPartitions),
- mtmvSchemaChangeVersion);
- if (refreshMode == RefreshMode.COMPLETE) {
- reconcileIvmStreams(ctx);
- }
- }
this.completedPartitions = Lists.newCopyOnWriteArrayList();
try {
// Snapshot persistence happens after refresh partitions are split
into execution groups. Load the
@@ -862,6 +938,121 @@ public class MTMVTask extends AbstractTask {
mtmv.getDatabase().getFullName(), mtmv.getName(), getTaskId());
}
+ /**
+ * Whether a base table the refresh reads has no stream that can be read,
which no attempt other
+ * than COMPLETE can work around.
+ *
+ * <p>A base table that cannot be resolved is skipped rather than judged:
it says nothing about the
+ * streams, and the refresh fails on it for its own reasons -- the attempt
that runs reports that,
+ * this one only decides which attempt that should be.
+ */
+ private boolean hasUnusableIvmStream() {
+ Database mvDb = (Database) mtmv.getDatabase();
+ if (mvDb == null) {
+ // Nothing to look the streams up in, so there is nothing to
decide here.
+ return false;
+ }
+ Set<TableNameInfo> excluded = mtmv.getExcludedTriggerTables();
+ // The tables in the plan, not the relation's closure: a chained MV is
created with a stream for
+ // every base table behind the MVs it reads, but no rewrite ever looks
those up -- the incremental
+ // rewriter and the full refresh take the streams of the plan's scans
-- so judging them would
+ // rebuild an MV whose refresh had nothing wrong with it.
+ for (BaseTableInfo baseTableInfo :
relation.getBaseTablesOneLevelAndFromView()) {
+ OlapTable baseTable = resolveIvmBaseTable(baseTableInfo);
+ if (baseTable == null) {
+ continue;
+ }
+ if (MTMVPartitionUtil.isTableExcluded(excluded,
+ new TableNameInfo(baseTable.getFullQualifiers()))) {
+ continue;
+ }
+ if (usableIvmStream(mvDb, baseTable) == null) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Whether a stream this partition refresh will read is missing or
unusable.
+ *
+ * <p>The set is narrower than the plan, and which tables are in it
depends on the partitions being
+ * refreshed: a PCT table that no refreshed partition's mapping names
keeps its place in the plan as
+ * an ordinary scan, so its stream is never read, and a table outside the
plan's own tables is not
+ * scanned at all. Judging the whole plan instead sends a partition
refresh that would have worked to
+ * a full rebuild whenever such an unread stream is missing.
+ */
+ private boolean hasUnusableIvmStreamForPartitions(MTMVRefreshContext
context,
+ List<String> mvPartitionNames) {
+ Database mvDb = (Database) mtmv.getDatabase();
+ if (mvDb == null) {
+ // Nothing to look the streams up in, so there is nothing to
decide here.
+ return false;
+ }
+ Set<TableNameInfo> excluded = mtmv.getExcludedTriggerTables();
+ Set<OlapTable> streamedTables = Sets.newLinkedHashSet();
+ Set<BaseTableInfo> pctTableInfos = Sets.newHashSet();
+ for (BaseColInfo pctInfo : mtmv.getMvPartitionInfo().getPctInfos()) {
+ pctTableInfos.add(pctInfo.getTableInfo());
+ }
+ // Every table of the plan that is not a PCT table is read through its
stream: the partition
+ // refresh gives those tables a read mode (IvmRewriteContext.full).
+ for (BaseTableInfo baseTableInfo :
relation.getBaseTablesOneLevelAndFromView()) {
+ if (pctTableInfos.contains(baseTableInfo)) {
+ continue;
+ }
+ OlapTable baseTable = resolveIvmBaseTable(baseTableInfo);
+ if (baseTable != null) {
+ streamedTables.add(baseTable);
+ }
+ }
+ // A PCT table is read through its stream only while the mapping of a
refreshed partition names
+ // it, which is exactly the mapping the reset read mode is built from.
+ for (String mvPartitionName : mvPartitionNames) {
+ for (MTMVRelatedTableIf relatedTable :
context.getByPartitionName(mvPartitionName).keySet()) {
+ if (relatedTable instanceof OlapTable) {
+ streamedTables.add((OlapTable) relatedTable);
+ }
+ }
+ }
+ for (OlapTable baseTable : streamedTables) {
+ if (MTMVPartitionUtil.isTableExcluded(excluded,
+ new TableNameInfo(baseTable.getFullQualifiers()))) {
+ continue;
+ }
+ if (usableIvmStream(mvDb, baseTable) == null) {
+ LOG.warn("IVM stream is unusable for the partitions this
refresh plans, mv={}, baseTable={}",
+ mtmv.getName(), baseTable.getName());
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * A base table of the plan, or null when it cannot be resolved -- which
says nothing about its stream,
+ * so a caller that is only judging streams skips it.
+ */
+ private OlapTable resolveIvmBaseTable(BaseTableInfo baseTableInfo) {
+ try {
+ return (OlapTable) MTMVUtil.getTable(baseTableInfo);
+ } catch (Exception e) {
+ LOG.warn("Cannot resolve base table {} of mv={}", baseTableInfo,
mtmv.getName(), e);
+ return null;
+ }
+ }
+
+ /** The MV's stream for the base table, or null when it is missing or
cannot be used. */
+ private BaseTableStream usableIvmStream(Database mvDb, OlapTable
baseTable) {
+ TableIf stream = mvDb.getTableNullable(
+ IvmUtil.streamName(mtmv.getId(),
baseTable.getFullQualifiers()));
+ if (stream instanceof BaseTableStream
+ && IvmUtil.isIvmStreamUsable((BaseTableStream) stream,
baseTable)) {
+ return (BaseTableStream) stream;
+ }
+ return null;
+ }
+
private void reconcileIvmStreams(ConnectContext ctx) throws JobException {
try {
Database mvDb = (Database) mtmv.getDatabase();
@@ -872,10 +1063,7 @@ public class MTMVTask extends AbstractTask {
new TableNameInfo(baseTable.getFullQualifiers()))) {
continue;
}
- String streamName = IvmUtil.streamName(mtmv.getId(),
baseTable.getFullQualifiers());
- TableIf stream = mvDb.getTableNullable(streamName);
- if (stream instanceof BaseTableStream
- && IvmUtil.isIvmStreamUsable((BaseTableStream) stream,
baseTable)) {
+ if (usableIvmStream(mvDb, baseTable) != null) {
continue;
}
CreateMTMVCommand.createTableStream(ctx, mvDb, mtmv,
baseTable);
@@ -1336,38 +1524,6 @@ public class MTMVTask extends AbstractTask {
}
}
- public List<String> calculateNeedRefreshPartitions(MTMVRefreshContext
context)
- throws AnalysisException, JobException {
- RefreshRequest request = resolveRefreshRequest();
- if (request.refreshMode == RefreshMode.COMPLETE) {
- return Lists.newArrayList(mtmv.getPartitionNames());
- }
- // check whether the user manually triggers it
- if (taskContext.getTriggerMode() == MTMVTaskTriggerMode.MANUAL) {
- if (!CollectionUtils.isEmpty(taskContext.getPartitions())) {
- return taskContext.getPartitions();
- }
- }
- // if refreshMethod is COMPLETE, we must FULL refresh, avoid external
table MTMV always not refresh
- if (mtmv.getRefreshInfo().getRefreshMethod() ==
RefreshMethod.COMPLETE) {
- return Lists.newArrayList(mtmv.getPartitionNames());
- }
- // We need to use a newly generated relationship and cannot retrieve
it using mtmv.getRelation()
- // to avoid rebuilding the baseTable and causing a change in the
tableId
- boolean fresh = MTMVPartitionUtil.isMTMVSync(context,
relation.getBaseTablesOneLevelAndFromView(),
- mtmv.getExcludedTriggerTables());
- if (fresh) {
- return Lists.newArrayList();
- }
- // current, if partitionType is SELF_MANAGE, we can only FULL refresh
- if (mtmv.getMvPartitionInfo().getPartitionType() ==
MTMVPartitionType.SELF_MANAGE) {
- return Lists.newArrayList(mtmv.getPartitionNames());
- }
- // We need to use a newly generated relationship and cannot retrieve
it using mtmv.getRelation()
- // to avoid rebuilding the baseTable and causing a change in the
tableId
- return MTMVPartitionUtil.getMTMVNeedRefreshPartitions(context,
relation.getBaseTablesOneLevelAndFromView());
- }
-
public MTMVTaskContext getTaskContext() {
return taskContext;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureReason.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureReason.java
index 61754e1556a..e57d74b3b90 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureReason.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmFailureReason.java
@@ -38,6 +38,7 @@ public enum IvmFailureReason {
public boolean requiresCompleteRefresh() {
return this == BINLOG_BROKEN
+ || this == STREAM_UNSUPPORTED
|| this == MIN_MAX_BOUNDARY_HIT
|| this == BITMAP_AGG_DELETE
|| this == PLAN_SIGNATURE_MISMATCH;
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
index 0f4d44634ca..47a0d2403e4 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
@@ -25,6 +25,7 @@ import org.apache.doris.catalog.MTMV;
import org.apache.doris.catalog.OlapTable;
import org.apache.doris.catalog.Partition;
import org.apache.doris.catalog.info.TableNameInfo;
+import org.apache.doris.catalog.stream.OlapTableStream;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.Config;
import org.apache.doris.common.DdlException;
@@ -50,6 +51,7 @@ import org.apache.doris.mtmv.ivm.IvmInfo;
import org.apache.doris.mtmv.ivm.IvmPlanSignature;
import org.apache.doris.mtmv.ivm.IvmPlanSignatureGenerator;
import org.apache.doris.mtmv.ivm.IvmRewriteResult;
+import org.apache.doris.mtmv.ivm.IvmUtil;
import org.apache.doris.nereids.CascadesContext;
import org.apache.doris.nereids.NereidsPlanner;
import org.apache.doris.nereids.StatementContext;
@@ -70,6 +72,7 @@ import org.apache.doris.thrift.TUniqueId;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import org.apache.commons.collections4.CollectionUtils;
import org.junit.jupiter.api.AfterEach;
@@ -142,23 +145,6 @@ public class MTMVTaskTest {
mtmvPartitionUtilStatic.close();
}
- @Test
- public void testCalculateNeedRefreshPartitionsManualComplete() throws
AnalysisException, JobException {
- MTMVTaskContext context =
MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, null, RefreshMode.COMPLETE);
- MTMVTask task = new MTMVTask(mtmv, relation, context);
- List<String> result = task.calculateNeedRefreshPartitions(null);
- Assertions.assertEquals(allPartitionNames, result);
- }
-
- @Test
- public void testCalculateNeedRefreshPartitionsManualPartitions() throws
AnalysisException, JobException {
- MTMVTaskContext context =
MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, Lists.newArrayList(poneName),
- RefreshMode.AUTO);
- MTMVTask task = new MTMVTask(mtmv, relation, context);
- List<String> result = task.calculateNeedRefreshPartitions(null);
- Assertions.assertEquals(Lists.newArrayList(poneName), result);
- }
-
@Test
public void testGenerateRefreshModeDistinguishesFullAndPartialScope() {
MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
@@ -244,92 +230,187 @@ public class MTMVTaskTest {
Assertions.assertEquals(Lists.newArrayList("PARTITIONS"),
toNames(attempts));
}
- private static List<String> toNames(List<?> attempts) {
- List<String> names = Lists.newArrayList();
- for (Object attempt : attempts) {
- names.add(String.valueOf(attempt));
- }
- return names;
- }
-
@Test
- public void testCalculateNeedRefreshPartitionsSystem() throws
AnalysisException, JobException {
-
Mockito.when(mtmvRefreshInfo.getRefreshMethod()).thenReturn(RefreshMethod.AUTO);
- MTMVTaskContext context = new
MTMVTaskContext(MTMVTaskTriggerMode.SYSTEM);
- MTMVTask task = new MTMVTask(mtmv, relation, context);
- List<String> result = task.calculateNeedRefreshPartitions(null);
- Assertions.assertTrue(CollectionUtils.isEmpty(result));
+ public void
testBuildAttemptsGoesStraightToCompleteWhenTheStreamIsUnusable() throws
Exception {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+
Mockito.when(mtmvRefreshInfo.getRefreshMethod()).thenReturn(RefreshMethod.INCREMENTAL);
+
Mockito.when(mtmv.getExcludedTriggerTables()).thenReturn(Collections.emptySet());
+
Mockito.when(mtmv.getFullQualifiers()).thenReturn(Lists.newArrayList("internal",
"db", "t1"));
+ // The MV's database holds no stream for the base table.
+
Mockito.when(mtmv.getDatabase()).thenReturn(Mockito.mock(Database.class));
+ mtmvUtilStatic.when(() ->
MTMVUtil.getTable(Mockito.any(BaseTableInfo.class))).thenReturn(mtmv);
+
+ MTMVTask task = new MTMVTask(mtmv, relationWithOneBaseTable(),
MTMVTaskContext.of(
+ MTMVTaskTriggerMode.MANUAL, null, RefreshMode.INCREMENTAL,
true, null));
+ Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
+ List<?> attempts = (List<?>) Deencapsulation.invoke(task,
"buildAttempts", request, false);
+
+ // Neither the incremental rewrite nor a partition refresh can read a
stream that is not there,
+ // and the IVM attempt would be rejected while a baseline barrier is
pending, so the refresh goes
+ // to the only attempt that reconciles the streams.
+ Assertions.assertEquals(Lists.newArrayList("COMPLETE"),
toNames(attempts));
+ Assertions.assertEquals(IvmFailureReason.STREAM_UNSUPPORTED.name(),
+ Deencapsulation.getField(task, "ivmFallbackReason"));
}
@Test
- public void testPlanPartitionRefreshSelfManageWhenSync() throws Exception {
-
Mockito.when(mtmvPartitionInfo.getPartitionType()).thenReturn(MTMVPartitionType.SELF_MANAGE);
- MTMVTask task = new MTMVTask(mtmv, relation,
- MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, null,
RefreshMode.AUTO));
+ public void testBuildAttemptsKeepsTheChainWhenTheStreamsAreUsable() throws
Exception {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ Mockito.when(mtmv.getId()).thenReturn(7L);
+
Mockito.when(mtmv.getFullQualifiers()).thenReturn(Lists.newArrayList("internal",
"db", "t1"));
+
Mockito.when(mtmvRefreshInfo.getRefreshMethod()).thenReturn(RefreshMethod.INCREMENTAL);
+
Mockito.when(mtmv.getExcludedTriggerTables()).thenReturn(Collections.emptySet());
+ OlapTableStream stream = Mockito.mock(OlapTableStream.class);
+ Mockito.when(stream.getBaseTableFullQualifiers())
+ .thenReturn(Lists.newArrayList("internal", "db", "t1"));
+ Mockito.when(stream.isDisabled()).thenReturn(false);
+ Mockito.when(stream.isStale()).thenReturn(false);
+ Mockito.when(stream.getBaseTableNullable()).thenReturn(mtmv);
+ Database mvDb = Mockito.mock(Database.class);
+
Mockito.when(mvDb.getTableNullable(Mockito.anyString())).thenReturn(stream);
+ Mockito.when(mtmv.getDatabase()).thenReturn(mvDb);
+ mtmvUtilStatic.when(() ->
MTMVUtil.getTable(Mockito.any(BaseTableInfo.class))).thenReturn(mtmv);
+
+ MTMVTask task = new MTMVTask(mtmv, relationWithOneBaseTable(),
MTMVTaskContext.of(
+ MTMVTaskTriggerMode.MANUAL, null, RefreshMode.INCREMENTAL,
true, null));
Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
+ List<?> attempts = (List<?>) Deencapsulation.invoke(task,
"buildAttempts", request, false);
- Object plan = Deencapsulation.invoke(task, "planPartitionRefresh",
- Mockito.mock(MTMVRefreshContext.class), request);
-
- Assertions.assertTrue((Boolean) Deencapsulation.getField(plan,
"canRefreshByPartitions"));
-
Assertions.assertTrue(CollectionUtils.isEmpty(Deencapsulation.getField(plan,
"partitions")));
+ // A usable stream is not a reason to refresh more than the request
asked for.
+ Assertions.assertEquals(Lists.newArrayList("IVM", "PARTITIONS",
"COMPLETE"), toNames(attempts));
}
@Test
- public void testCalculateNeedRefreshPartitionsSystemComplete() throws
AnalysisException, JobException {
- MTMVTaskContext context = new
MTMVTaskContext(MTMVTaskTriggerMode.SYSTEM);
- MTMVTask task = new MTMVTask(mtmv, relation, context);
- List<String> result = task.calculateNeedRefreshPartitions(null);
- Assertions.assertEquals(allPartitionNames, result);
+ public void testBuildAttemptsIgnoresAStreamOnlyTheClosureCarries() throws
Exception {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ Mockito.when(mtmv.getId()).thenReturn(7L);
+
Mockito.when(mtmvRefreshInfo.getRefreshMethod()).thenReturn(RefreshMethod.INCREMENTAL);
+
Mockito.when(mtmv.getExcludedTriggerTables()).thenReturn(Collections.emptySet());
+ // The MV of a chain reads the upstream MV, and the upstream's own
base table is in the relation
+ // only because the closure carries it: (t1) => upstream => mv.
+ OlapTable upstream = Mockito.mock(OlapTable.class);
+
Mockito.when(upstream.getFullQualifiers()).thenReturn(Lists.newArrayList("internal",
"db", "upstream"));
+ OlapTable grandParent = Mockito.mock(OlapTable.class);
+
Mockito.when(grandParent.getFullQualifiers()).thenReturn(Lists.newArrayList("internal",
"db", "t1"));
+ BaseTableInfo upstreamInfo = Mockito.mock(BaseTableInfo.class);
+ BaseTableInfo grandParentInfo = Mockito.mock(BaseTableInfo.class);
+ mtmvUtilStatic.when(() ->
MTMVUtil.getTable(upstreamInfo)).thenReturn(upstream);
+ mtmvUtilStatic.when(() ->
MTMVUtil.getTable(grandParentInfo)).thenReturn(grandParent);
+ // The upstream's stream is there, the grandparent's is not.
+ OlapTableStream stream = Mockito.mock(OlapTableStream.class);
+ Mockito.when(stream.getBaseTableFullQualifiers())
+ .thenReturn(Lists.newArrayList("internal", "db", "upstream"));
+ Mockito.when(stream.isDisabled()).thenReturn(false);
+ Mockito.when(stream.isStale()).thenReturn(false);
+ Mockito.when(stream.getBaseTableNullable()).thenReturn(upstream);
+ Database mvDb = Mockito.mock(Database.class);
+ Mockito.when(mvDb.getTableNullable(IvmUtil.streamName(7L,
upstream.getFullQualifiers())))
+ .thenReturn(stream);
+ Mockito.when(mtmv.getDatabase()).thenReturn(mvDb);
+ MTMVRelation chainedRelation = new
MTMVRelation(Sets.newHashSet(upstreamInfo, grandParentInfo),
+ Sets.newHashSet(upstreamInfo), Sets.newHashSet(upstreamInfo),
Sets.newHashSet(),
+ Sets.newHashSet());
+
+ MTMVTask task = new MTMVTask(mtmv, chainedRelation, MTMVTaskContext.of(
+ MTMVTaskTriggerMode.MANUAL, null, RefreshMode.INCREMENTAL,
true, null));
+ Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
+ List<?> attempts = (List<?>) Deencapsulation.invoke(task,
"buildAttempts", request, false);
+
+ // No rewrite reads the grandparent's stream, so its absence is no
reason to rebuild the MV.
+ Assertions.assertEquals(Lists.newArrayList("IVM", "PARTITIONS",
"COMPLETE"), toNames(attempts));
+ Assertions.assertNull(Deencapsulation.getField(task,
"ivmFallbackReason"));
}
@Test
- public void
testCalculateNeedRefreshPartitionsSystemIncompleteRefreshSnapshot() throws
AnalysisException, JobException {
-
Mockito.when(mtmvRefreshInfo.getRefreshMethod()).thenReturn(RefreshMethod.AUTO);
- Mockito.when(mtmv.hasRefreshSnapshot()).thenReturn(false);
+ public void testPartitionRefreshChecksOnlyTheStreamsItsPartitionsRead()
throws Exception {
+ // t1 UNION ALL t2, both PCT tables of the MV, each backing one of its
partitions. Refreshing the
+ // t1-backed partition reads t1's stream and leaves t2 to an ordinary
scan, so t2's stream is not
+ // part of this refresh and its absence must not send it to COMPLETE.
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ Mockito.when(mtmv.getId()).thenReturn(7L);
+
Mockito.when(mtmv.getExcludedTriggerTables()).thenReturn(Collections.emptySet());
+ OlapTable t1 = mockBaseTable("t1");
+ OlapTable t2 = mockBaseTable("t2");
+ BaseTableInfo t1Info = Mockito.mock(BaseTableInfo.class);
+ BaseTableInfo t2Info = Mockito.mock(BaseTableInfo.class);
+ mtmvUtilStatic.when(() -> MTMVUtil.getTable(t1Info)).thenReturn(t1);
+ mtmvUtilStatic.when(() -> MTMVUtil.getTable(t2Info)).thenReturn(t2);
+ OlapTableStream t1Stream = usableStreamFor(t1);
+ Database mvDb = Mockito.mock(Database.class);
+ Mockito.when(mvDb.getTableNullable(IvmUtil.streamName(7L,
t1.getFullQualifiers())))
+ .thenReturn(t1Stream);
+ Mockito.when(mtmv.getDatabase()).thenReturn(mvDb);
+
Mockito.when(mtmvPartitionInfo.getPctInfos()).thenReturn(Lists.newArrayList(
+ new BaseColInfo("dt", t1Info), new BaseColInfo("dt", t2Info)));
+
+ // The partition this refresh plans reads t1, and t2 keeps its place
in the plan as a plain scan.
+ Map<MTMVRelatedTableIf, Set<String>> mapping = Maps.newHashMap();
+ mapping.put(t1, Sets.newHashSet("p1"));
+ MTMVRefreshContext context = Mockito.mock(MTMVRefreshContext.class);
+
Mockito.when(context.getByPartitionName(Mockito.anyString())).thenReturn(mapping);
+
+ MTMVRelation relation = new MTMVRelation(Sets.newHashSet(t1Info,
t2Info), Sets.newHashSet(t1Info, t2Info),
+ Sets.newHashSet(t1Info, t2Info), Sets.newHashSet(),
Sets.newHashSet());
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
- MTMVTaskContext context = new
MTMVTaskContext(MTMVTaskTriggerMode.SYSTEM);
- MTMVTask task = new MTMVTask(mtmv, relation, context);
- List<String> result = task.calculateNeedRefreshPartitions(null);
+ Assertions.assertFalse((Boolean) Deencapsulation.invoke(task,
"hasUnusableIvmStreamForPartitions",
+ context, Lists.newArrayList("p_t1")));
- Assertions.assertTrue(CollectionUtils.isEmpty(result));
- mtmvPartitionUtilStatic.verify(() -> MTMVPartitionUtil.isMTMVSync(
- Mockito.nullable(MTMVRefreshContext.class),
Mockito.nullable(Set.class), Mockito.nullable(Set.class)));
+ // Once a refreshed partition's mapping names t2, its stream is read,
and its absence decides.
+ mapping.put(t2, Sets.newHashSet("p2"));
+ Assertions.assertTrue((Boolean) Deencapsulation.invoke(task,
"hasUnusableIvmStreamForPartitions",
+ context, Lists.newArrayList("p_t1")));
}
- @Test
- public void
testCalculateNeedRefreshPartitionsManualPartitionsIncompleteRefreshSnapshot()
- throws AnalysisException, JobException {
- Mockito.when(mtmv.hasRefreshSnapshot()).thenReturn(false);
+ private OlapTable mockBaseTable(String name) {
+ OlapTable baseTable = Mockito.mock(OlapTable.class);
+ Mockito.when(baseTable.getName()).thenReturn(name);
+
Mockito.when(baseTable.getFullQualifiers()).thenReturn(Lists.newArrayList("internal",
"db", name));
+ return baseTable;
+ }
- MTMVTaskContext context =
MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, Lists.newArrayList(poneName),
- RefreshMode.PARTITIONS, false, null);
- MTMVTask task = new MTMVTask(mtmv, relation, context);
- List<String> result = task.calculateNeedRefreshPartitions(null);
+ /** A stream that {@code IvmUtil.isIvmStreamUsable} accepts for the given
base table. */
+ private OlapTableStream usableStreamFor(OlapTable baseTable) {
+ List<String> qualifiers = baseTable.getFullQualifiers();
+ OlapTableStream stream = Mockito.mock(OlapTableStream.class);
+
Mockito.when(stream.getBaseTableFullQualifiers()).thenReturn(qualifiers);
+ Mockito.when(stream.isDisabled()).thenReturn(false);
+ Mockito.when(stream.isStale()).thenReturn(false);
+ Mockito.when(stream.getBaseTableNullable()).thenReturn(baseTable);
+ return stream;
+ }
- Assertions.assertEquals(Lists.newArrayList(poneName), result);
+ private MTMVRelation relationWithOneBaseTable() {
+ BaseTableInfo baseTable = Mockito.mock(BaseTableInfo.class);
+ // A table of the query is in the plan, in the first level of the
query, and in the closure.
+ return new MTMVRelation(Sets.newHashSet(baseTable),
Sets.newHashSet(baseTable),
+ Sets.newHashSet(baseTable), Sets.newHashSet(),
Sets.newHashSet());
}
- @Test
- public void testCalculateNeedRefreshPartitionsSystemNotSyncComplete()
throws AnalysisException, JobException {
- mtmvPartitionUtilStatic.when(() ->
MTMVPartitionUtil.isMTMVSync(Mockito.nullable(MTMVRefreshContext.class),
Mockito.nullable(Set.class), Mockito.nullable(Set.class))).thenReturn(false);
- MTMVTaskContext context = new
MTMVTaskContext(MTMVTaskTriggerMode.SYSTEM);
- MTMVTask task = new MTMVTask(mtmv, relation, context);
- List<String> result = task.calculateNeedRefreshPartitions(null);
- Assertions.assertEquals(allPartitionNames, result);
+ private static List<String> toNames(List<?> attempts) {
+ List<String> names = Lists.newArrayList();
+ for (Object attempt : attempts) {
+ names.add(String.valueOf(attempt));
+ }
+ return names;
}
@Test
- public void testCalculateNeedRefreshPartitionsSystemNotSyncAuto() throws
AnalysisException, JobException {
- mtmvPartitionUtilStatic.when(() ->
MTMVPartitionUtil.isMTMVSync(Mockito.nullable(MTMVRefreshContext.class),
Mockito.nullable(Set.class), Mockito.nullable(Set.class))).thenReturn(false);
+ public void testPlanPartitionRefreshSelfManageWhenSync() throws Exception {
+
Mockito.when(mtmvPartitionInfo.getPartitionType()).thenReturn(MTMVPartitionType.SELF_MANAGE);
+ MTMVTask task = new MTMVTask(mtmv, relation,
+ MTMVTaskContext.of(MTMVTaskTriggerMode.MANUAL, null,
RefreshMode.AUTO));
+ Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
-
Mockito.when(mtmvRefreshInfo.getRefreshMethod()).thenReturn(RefreshMethod.AUTO);
+ Object plan = Deencapsulation.invoke(task, "planPartitionRefresh",
+ Mockito.mock(MTMVRefreshContext.class), request);
- mtmvPartitionUtilStatic.when(() ->
MTMVPartitionUtil.getMTMVNeedRefreshPartitions(Mockito.nullable(MTMVRefreshContext.class),
Mockito.nullable(Set.class))).thenReturn(Lists.newArrayList(ptwoName));
- MTMVTaskContext context = new
MTMVTaskContext(MTMVTaskTriggerMode.SYSTEM);
- MTMVTask task = new MTMVTask(mtmv, relation, context);
- List<String> result = task.calculateNeedRefreshPartitions(null);
- Assertions.assertEquals(Lists.newArrayList(ptwoName), result);
+ Assertions.assertTrue((Boolean) Deencapsulation.getField(plan,
"canRefreshByPartitions"));
+
Assertions.assertTrue(CollectionUtils.isEmpty(Deencapsulation.getField(plan,
"partitions")));
}
@Test
@@ -377,14 +458,14 @@ public class MTMVTaskTest {
}
@Test
- public void testMvDefaultUnknownRefreshMethodRejected() throws
AnalysisException {
+ public void testMvDefaultUnknownRefreshMethodRejected() {
Mockito.when(mtmv.getName()).thenReturn("test_mv");
Mockito.when(mtmvRefreshInfo.getRefreshMethod()).thenReturn(null);
MTMVTaskContext context =
MTMVTaskContext.forMvDefault(MTMVTaskTriggerMode.SYSTEM);
MTMVTask task = new MTMVTask(mtmv, relation, context);
JobException exception = Assertions.assertThrows(JobException.class,
- () -> task.calculateNeedRefreshPartitions(null));
+ () -> Deencapsulation.invoke(task, "resolveRefreshRequest"));
Assertions.assertTrue(exception.getMessage().contains("unknown refresh
method"));
}
@@ -832,6 +913,95 @@ public class MTMVTaskTest {
Mockito.verify(mtmv,
Mockito.never()).releaseIvmBaselineRebuild(Mockito.anyLong());
}
+ @Test
+ public void testPendingBaselineRebuildChecksTheStreamsItsPartitionsRead()
throws Exception {
+ // A partial barrier left by an earlier failed refresh. The pre-step
rebuilds those partitions
+ // before the attempts run, and that rebuild reads their streams, so a
stream missing for them
+ // decides the request just as it does for the partition attempt --
and PARTITIONS FALLBACK
+ // reaches this pre-step without an IVM attempt for buildAttempts to
have judged.
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ Mockito.when(mtmv.getName()).thenReturn("test_mv");
+ Mockito.when(mtmv.getId()).thenReturn(7L);
+
Mockito.when(mtmv.getExcludedTriggerTables()).thenReturn(Collections.emptySet());
+ IvmInfo ivmInfo = new IvmInfo();
+ ivmInfo.addPendingBaselineRebuildPartitions(Sets.newHashSet(poneName));
+ Mockito.when(mtmv.getIvmInfo()).thenReturn(ivmInfo);
+ OlapTable t1 = mockBaseTable("t1");
+ BaseTableInfo t1Info = Mockito.mock(BaseTableInfo.class);
+ mtmvUtilStatic.when(() -> MTMVUtil.getTable(t1Info)).thenReturn(t1);
+ // t1 is not a PCT table, so every partition this rebuild refreshes
reads it through its stream,
+ // and the MV's database holds no stream for it.
+
Mockito.when(mtmv.getDatabase()).thenReturn(Mockito.mock(Database.class));
+ MTMVRefreshContext context = Mockito.mock(MTMVRefreshContext.class);
+
Mockito.when(context.getByPartitionName(Mockito.anyString())).thenReturn(Maps.newHashMap());
+
+ MTMVRelation relation = new MTMVRelation(Sets.newHashSet(t1Info),
Sets.newHashSet(t1Info),
+ Sets.newHashSet(t1Info), Sets.newHashSet(), Sets.newHashSet());
+ MTMVTask task = new MTMVTask(mtmv, relation, MTMVTaskContext.of(
+ MTMVTaskTriggerMode.MANUAL, null, RefreshMode.PARTITIONS,
true, null));
+ Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
+ List<Object> attempts = Lists.newArrayList();
+ attempts.addAll(Deencapsulation.invoke(task, "buildAttempts", request,
false));
+ Assertions.assertEquals("[PARTITIONS, COMPLETE]", attempts.toString());
+
+ try {
+ Deencapsulation.invoke(task, "handlePendingIvmBaselineRebuild",
context, request,
+ new ConnectContext(), attempts);
+ } catch (Exception expected) {
+ // Without the stream check the pre-step rebuilds inline, and how
far that rebuild gets
+ // against these mocks is not what this test is about; the
attempts it leaves behind are.
+ }
+
+ // The rebuild that cannot read its streams is skipped rather than
attempted: its own barrier
+ // would have guarded nothing but the data it never wrote, and the
COMPLETE attempt left in the
+ // list reconciles the stream and clears the barrier that is already
pending.
+ Assertions.assertEquals("[COMPLETE]", attempts.toString());
+ Assertions.assertEquals(IvmFailureReason.STREAM_UNSUPPORTED.name(),
+ Deencapsulation.getField(task, "ivmFallbackReason"));
+ Mockito.verify(mtmv,
Mockito.never()).persistIvmBaselineGuard(Mockito.any(), Mockito.anySet(),
+ Mockito.anyLong());
+ Mockito.verify(mtmv,
Mockito.never()).releaseIvmBaselineRebuild(Mockito.anyLong());
+
+ // The same rebuild without fallback is not covered by a COMPLETE
attempt, so it fails here
+ // rather than starting a rebuild that cannot read its streams.
+ MTMVTask strictTask = new MTMVTask(mtmv, relation, MTMVTaskContext.of(
+ MTMVTaskTriggerMode.MANUAL, null, RefreshMode.PARTITIONS,
false, null));
+ Object strictRequest = Deencapsulation.invoke(strictTask,
"resolveRefreshRequest");
+ List<Object> strictAttempts = Lists.newArrayList();
+ strictAttempts.addAll(Deencapsulation.invoke(strictTask,
"buildAttempts", strictRequest, false));
+ Assertions.assertEquals("[PARTITIONS]", strictAttempts.toString());
+
+ JobException exception = Assertions.assertThrows(JobException.class,
+ () -> Deencapsulation.invoke(strictTask,
"handlePendingIvmBaselineRebuild", context,
+ strictRequest, new ConnectContext(), strictAttempts));
+
+ Assertions.assertTrue(exception.getMessage().contains("IVM stream is
unusable"));
+ }
+
+ @Test
+ public void testCompleteAttemptWritesTheBarrierBeforeReconcilingStreams()
throws Exception {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+
Mockito.when(mtmv.getPartitionNames()).thenReturn(Sets.newHashSet(poneName));
+ // The reconcile starts from the MV's database, which is what makes it
visible to the order check.
+
Mockito.when(mtmv.getDatabase()).thenReturn(Mockito.mock(Database.class));
+ MTMVTask task = new MTMVTask(mtmv, relation, new
MTMVTaskContext(MTMVTaskTriggerMode.MANUAL));
+ InOrder inOrder = Mockito.inOrder(mtmv);
+
+ try {
+ Deencapsulation.invoke(task, "executeCompleteAttempt",
+ Mockito.mock(MTMVRefreshContext.class), new
ConnectContext());
+ } catch (Exception expected) {
+ // How far the rebuild itself gets is not what this test is about.
+ }
+
+ // A recreated stream starts from the base table's current rows, so
the barrier that makes the
+ // next refresh rebuild the MV has to be durable before the stream is
replaced. The other order
+ // loses those rows with no error anywhere.
+ inOrder.verify(mtmv).persistIvmBaselineGuard(Mockito.any(),
Mockito.anySet(), Mockito.anyLong());
+ inOrder.verify(mtmv).getDatabase();
+ }
+
+
@Test
public void testDroppedBaselinePartitionsReleaseBarrierWithoutRebuild()
throws Exception {
Mockito.when(mtmv.isIvm()).thenReturn(true);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmFailureReasonTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmFailureReasonTest.java
index 3660f830ad3..46df766b99a 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmFailureReasonTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmFailureReasonTest.java
@@ -25,6 +25,9 @@ public class IvmFailureReasonTest {
@Test
public void testRequiresCompleteRefresh() {
Assertions.assertTrue(IvmFailureReason.BINLOG_BROKEN.requiresCompleteRefresh());
+ // An unusable stream can only be recreated by the COMPLETE path's
reconcile step; a
+ // PARTITIONS fallback would read the same stream and fail again.
+
Assertions.assertTrue(IvmFailureReason.STREAM_UNSUPPORTED.requiresCompleteRefresh());
Assertions.assertTrue(IvmFailureReason.MIN_MAX_BOUNDARY_HIT.requiresCompleteRefresh());
Assertions.assertTrue(IvmFailureReason.BITMAP_AGG_DELETE.requiresCompleteRefresh());
Assertions.assertTrue(IvmFailureReason.PLAN_SIGNATURE_MISMATCH.requiresCompleteRefresh());
diff --git a/regression-test/data/mtmv_p0/ivm/test_ivm_chained_stream_scope.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_chained_stream_scope.out
new file mode 100644
index 00000000000..d11be25f06d
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_chained_stream_scope.out
@@ -0,0 +1,6 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !downstream_mv --
+1 10
+2 20
+3 30
+
diff --git
a/regression-test/data/mtmv_p0/ivm/test_ivm_partitions_fallback_stream_unusable.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_partitions_fallback_stream_unusable.out
new file mode 100644
index 00000000000..7b7862c7774
--- /dev/null
+++
b/regression-test/data/mtmv_p0/ivm/test_ivm_partitions_fallback_stream_unusable.out
@@ -0,0 +1,13 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !baseline_mv --
+1 2026-01-10 100 dim-a
+2 2026-02-10 200 dim-b
+
+-- !reconciled_mv --
+1 2026-01-10 100 dim-a
+2 2026-02-10 200 dim-b
+
+-- !after_strict_failure_mv --
+1 2026-01-10 100 dim-a
+2 2026-02-10 200 dim-b
+
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_chained_stream_scope.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_chained_stream_scope.groovy
new file mode 100644
index 00000000000..895d42646fd
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_chained_stream_scope.groovy
@@ -0,0 +1,162 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+/**
+ * Which streams decide that a refresh needs the complete attempt.
+ *
+ * <p>An IVM MV is created with a stream for every base table in its relation,
and for a chained MV the
+ * relation carries more than the plan reads: mv2 reads mv1, while the table
behind mv1 is in mv2's
+ * relation only through the closure. Both rewrites that read streams -- the
incremental delta rewriter
+ * and the full refresh -- take the streams of the plan's scans, so mv2's
stream on that table is never
+ * read, and its absence must not turn mv2's incremental refresh into a
rebuild of the whole MV.
+ *
+ * <p>Pinned here:
+ * <ol>
+ * <li>the unused stream exists, so the setup is the one the case is
about;</li>
+ * <li>an incremental refresh of the upstream MV succeeds with it dropped,
which is what makes the
+ * refresh below able to stay incremental;</li>
+ * <li>the downstream MV still refreshes incrementally rather than falling
back to a complete
+ * refresh for a stream nothing reads.</li>
+ * </ol>
+ */
+suite("test_ivm_chained_stream_scope") {
+ def baseTable = "ivm_chained_scope_base"
+ def upstreamMv = "ivm_chained_scope_up"
+ def downstreamMv = "ivm_chained_scope_down"
+
+ def waitForNewTask = { String mvName, String previousTaskId ->
+ def taskResult
+ Awaitility.await().atMost(300, SECONDS).pollInterval(2,
SECONDS).until({
+ taskResult = sql_return_maparray("""
+ SELECT TaskId, Status
+ FROM tasks('type'='mv')
+ WHERE MvDatabaseName = '${context.dbName}'
+ AND MvName = '${mvName}'
+ ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+ """)
+ return !taskResult.isEmpty()
+ && taskResult[0].TaskId.toString() != previousTaskId
+ && taskResult[0].Status.toString() != 'PENDING'
+ && taskResult[0].Status.toString() != 'RUNNING'
+ })
+ return taskResult[0].TaskId.toString()
+ }
+
+ def taskRecord = { String taskId ->
+ // Unset RefreshMode comes back as the literal two-character string
"\N", which does not survive
+ // the .out round trip, so fold the unset value into a printable token.
+ return sql_return_maparray("""
+ SELECT Status,
+ CASE WHEN RefreshMode IN ('COMPLETE', 'PARTIAL',
'NOT_REFRESH')
+ THEN RefreshMode ELSE 'NONE' END AS RefreshMode
+ FROM tasks('type'='mv')
+ WHERE TaskId = '${taskId}'
+ """)[0]
+ }
+
+ def mvId = { String mvName ->
+ def rows = sql_return_maparray("""
+ SELECT Id FROM mv_infos('database'='${context.dbName}') WHERE Name
= '${mvName}'
+ """)
+ return rows[0].Id.toString()
+ }
+
+ // Streams are named after the MV that owns them, so the ones of the
downstream MV on the upstream's
+ // base table can be told apart from the ones the upstream MV has on it.
+ def streamNamesOf = { String mvName, String baseTableName ->
+ def rows = sql_return_maparray("""
+ SELECT STREAM_NAME FROM information_schema.table_streams
+ WHERE DB_NAME = '${context.dbName}' AND BASE_TABLE_NAME =
'${baseTableName}'
+ """)
+ def prefix = "__doris_ivm_stream_${mvId(mvName)}_"
+ return rows.collect { it.STREAM_NAME.toString() }.findAll {
it.startsWith(prefix) }
+ }
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${downstreamMv}"""
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${upstreamMv}"""
+ sql """DROP TABLE IF EXISTS ${baseTable}"""
+
+ sql """
+ CREATE TABLE ${baseTable} (
+ k1 INT NOT NULL,
+ v1 INT
+ )
+ UNIQUE KEY(k1)
+ DISTRIBUTED BY HASH(k1) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+ sql """INSERT INTO ${baseTable} VALUES (1, 10), (2, 20)"""
+
+ // The upstream MV carries row binlog, which is what the downstream MV
reads its changes from.
+ sql """
+ CREATE MATERIALIZED VIEW ${upstreamMv}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ AS SELECT * FROM ${baseTable}
+ """
+ sql """REFRESH MATERIALIZED VIEW ${upstreamMv} COMPLETE"""
+ def upstreamTaskId = waitForNewTask(upstreamMv, null)
+
+ sql """
+ CREATE MATERIALIZED VIEW ${downstreamMv}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ DISTRIBUTED BY RANDOM BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT k1, v1 FROM ${upstreamMv}
+ """
+ sql """REFRESH MATERIALIZED VIEW ${downstreamMv} COMPLETE"""
+ def downstreamTaskId = waitForNewTask(downstreamMv, null)
+
+ // The downstream MV's plan scans the upstream MV only, but its relation
also carries the upstream's
+ // base table, and the MV was created with a stream for it.
+ def unusedStreams = streamNamesOf(downstreamMv, baseTable)
+ assertEquals(1, unusedStreams.size())
+ sql """DROP STREAM ${context.dbName}.${unusedStreams[0]} FORCE"""
+ assertEquals(0, streamNamesOf(downstreamMv, baseTable).size())
+
+ // One change reaches the downstream MV through the upstream MV, so the
refresh below has something
+ // to apply and cannot pass as an empty incremental refresh.
+ sql """INSERT INTO ${baseTable} VALUES (3, 30)"""
+ sql """REFRESH MATERIALIZED VIEW ${upstreamMv} INCREMENTAL"""
+ upstreamTaskId = waitForNewTask(upstreamMv, upstreamTaskId)
+ assertEquals("SUCCESS", taskRecord(upstreamTaskId).Status.toString())
+
+ sql """REFRESH MATERIALIZED VIEW ${downstreamMv} AUTO"""
+ downstreamTaskId = waitForNewTask(downstreamMv, downstreamTaskId)
+ def downstreamTask = taskRecord(downstreamTaskId)
+ // An incremental refresh of an IVM MV records no refresh mode, so an
unset mode is the incremental
+ // attempt succeeding; COMPLETE would say the whole MV was rebuilt.
+ assertEquals("SUCCESS", downstreamTask.Status.toString())
+ assertEquals("NONE", downstreamTask.RefreshMode.toString())
+ order_qt_downstream_mv """SELECT k1, v1 FROM ${downstreamMv}"""
+}
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partitions_fallback_stream_unusable.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partitions_fallback_stream_unusable.groovy
new file mode 100644
index 00000000000..c9ca457fab8
--- /dev/null
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partitions_fallback_stream_unusable.groovy
@@ -0,0 +1,187 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.awaitility.Awaitility
+
+import static java.util.concurrent.TimeUnit.SECONDS
+
+/**
+ * What a partition-based refresh does when the MV's base table streams are
gone.
+ *
+ * <p>A partition refresh reads the stream of every base table it realigns
from -- the PCT tables of the
+ * partitions it rebuilds, and the non-PCT tables the MV joins -- so a missing
stream makes it fail,
+ * and only the COMPLETE attempt reconciles streams. A request that allows
falling back must therefore
+ * end up doing COMPLETE rather than failing the task; a request that does not
allow it must keep
+ * failing, because COMPLETE would refresh more than it was asked to.
+ *
+ * <p>Driven by dropping the joined table's stream by hand: that is the state
the fallback exists for and the one a
+ * unit test can only mock.
+ */
+suite("test_ivm_partitions_fallback_stream_unusable") {
+ def factTable = "ivm_stream_f"
+ def dimTable = "ivm_stream_d"
+ def mvName = "ivm_stream_mv"
+
+ def waitForNewTask = { previousTaskId ->
+ def taskResult
+ Awaitility.await().atMost(300, SECONDS).pollInterval(2,
SECONDS).until({
+ taskResult = sql_return_maparray("""
+ SELECT TaskId, Status
+ FROM tasks('type'='mv')
+ WHERE MvDatabaseName = '${context.dbName}'
+ AND MvName = '${mvName}'
+ ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+ """)
+ return !taskResult.isEmpty()
+ && taskResult[0].TaskId.toString() != previousTaskId
+ && taskResult[0].Status.toString() != 'PENDING'
+ && taskResult[0].Status.toString() != 'RUNNING'
+ })
+ return taskResult[0].TaskId.toString()
+ }
+
+ // Unset IvmFallbackReason comes back as the literal two-character string
"\N", which does not
+ // survive a .out round trip, so fold it into a printable token.
+ def taskOutcome = { String taskId ->
+ def rows = sql_return_maparray("""
+ SELECT Status,
+ CASE WHEN RefreshMode IN ('COMPLETE', 'PARTIAL',
'NOT_REFRESH')
+ THEN RefreshMode ELSE 'NONE' END AS Mode,
+ CASE WHEN IvmFallbackReason = 'BINLOG_BROKEN' OR
IvmFallbackReason = 'STREAM_UNSUPPORTED'
+ THEN IvmFallbackReason ELSE 'NONE' END AS Fallback
+ FROM tasks('type'='mv')
+ WHERE TaskId = '${taskId}'
+ """)
+ // toString: a GString never equals a String, and these are compared
against literals.
+ return
"${rows[0].Status}\t${rows[0].Mode}\t${rows[0].Fallback}".toString()
+ }
+
+ def taskStatus = { String taskId ->
+ def rows = sql_return_maparray("""
+ SELECT Status FROM tasks('type'='mv') WHERE TaskId = '${taskId}'
+ """)
+ return rows[0].Status.toString()
+ }
+
+ // The streams of every suite in this directory live in the one database
the runner gives them, so
+ // they have to be looked up by base table: taking every stream of the
database would take the
+ // streams of the suites running next to this one, and fail them with "IVM
stream not found".
+ def streamNameOf = { String tableName ->
+ def rows = sql("""
+ SELECT STREAM_NAME FROM information_schema.table_streams
+ WHERE DB_NAME = '${context.dbName}' AND BASE_TABLE_NAME =
'${tableName}'
+ """)
+ return rows.isEmpty() ? null : rows[0][0].toString()
+ }
+
+ def dropStreamOf = { String tableName ->
+ def name = streamNameOf(tableName)
+ assertTrue(name != null, "the MV should have created a stream for " +
tableName)
+ sql """DROP STREAM ${name} FORCE"""
+ }
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ${factTable}"""
+ sql """DROP TABLE IF EXISTS ${dimTable}"""
+
+ sql """
+ CREATE TABLE ${factTable} (
+ order_id BIGINT NOT NULL,
+ dt DATE NOT NULL,
+ dimension_id INT,
+ amount INT
+ )
+ UNIQUE KEY(order_id, dt)
+ PARTITION BY RANGE(dt) ()
+ DISTRIBUTED BY HASH(order_id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+ sql """ALTER TABLE ${factTable} ADD PARTITION p202601 VALUES
[('2026-01-01'), ('2026-02-01'))"""
+ sql """ALTER TABLE ${factTable} ADD PARTITION p202602 VALUES
[('2026-02-01'), ('2026-03-01'))"""
+
+ // Partitioned as well, but joined on a non-partition column: a base table
of the MV that is not one
+ // of its PCT tables, and therefore read through its stream by a partition
refresh.
+ sql """
+ CREATE TABLE ${dimTable} (
+ dimension_id INT NOT NULL,
+ dt DATE NOT NULL,
+ dimension_name VARCHAR(32)
+ )
+ UNIQUE KEY(dimension_id, dt)
+ PARTITION BY RANGE(dt) ()
+ DISTRIBUTED BY HASH(dimension_id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+ sql """ALTER TABLE ${dimTable} ADD PARTITION d202601 VALUES
[('2026-01-01'), ('2026-02-01'))"""
+ sql """ALTER TABLE ${dimTable} ADD PARTITION d202602 VALUES
[('2026-02-01'), ('2026-03-01'))"""
+
+ sql """INSERT INTO ${dimTable} VALUES (10, '2026-01-15', 'dim-a'), (20,
'2026-02-15', 'dim-b')"""
+ sql """INSERT INTO ${factTable} VALUES
+ (1, '2026-01-10', 10, 100),
+ (2, '2026-02-10', 20, 200)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ${mvName}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ KEY(order_id, dt)
+ PARTITION BY(dt)
+ DISTRIBUTED BY HASH(order_id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT f.order_id, f.dt, f.amount, d.dimension_name
+ FROM ${factTable} f
+ LEFT JOIN ${dimTable} d ON f.dimension_id = d.dimension_id
+ """
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} COMPLETE"""
+ def taskId = waitForNewTask(null)
+ assertEquals("SUCCESS\tCOMPLETE\tNONE", taskOutcome(taskId))
+ order_qt_baseline_mv """SELECT order_id, dt, amount, dimension_name FROM
${mvName}"""
+
+ // With the streams gone, a partition refresh cannot read anything.
Falling back is what makes it
+ // reach the COMPLETE attempt, which reconciles the streams and rebuilds
the MV; without it the
+ // refresh would fail and stay failed.
+ dropStreamOf(dimTable)
+ sql """REFRESH MATERIALIZED VIEW ${mvName} PARTITIONS FALLBACK"""
+ taskId = waitForNewTask(taskId)
+ assertEquals("SUCCESS\tCOMPLETE\tSTREAM_UNSUPPORTED", taskOutcome(taskId))
+ assertTrue(streamNameOf(dimTable) != null, "the complete attempt should
have reconciled the stream")
+ order_qt_reconciled_mv """SELECT order_id, dt, amount, dimension_name FROM
${mvName}"""
+
+ // The same request without fallback keeps failing: COMPLETE refreshes
more than the request asked
+ // for, so it may only be reached when the user allowed it. Its failure
leaves the MV as it was.
+ //
+ // The base table has to be out of sync for the partition refresh to do
anything at all: an MV that
+ // is already up to date refreshes no partition, and therefore never reads
a stream.
+ sql """INSERT INTO ${factTable} VALUES (3, '2026-01-20', 10, 300)"""
+ dropStreamOf(dimTable)
+ sql """REFRESH MATERIALIZED VIEW ${mvName} PARTITIONS"""
+ taskId = waitForNewTask(taskId)
+ assertEquals("FAILED", taskStatus(taskId))
+ order_qt_after_strict_failure_mv """SELECT order_id, dt, amount,
dimension_name FROM ${mvName}"""
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]