This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch branch-incremental-computation
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-incremental-computation
by this push:
new 29b9e48a9bc branch-incremental-computation: pick the merged
incremental-computation PRs from master in merge order (#67753 #67802 #67814
#67837 #67853 #67876) (#68017)
29b9e48a9bc is described below
commit 29b9e48a9bcd4bc4dc86f17e5ca5adfcde9697be
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Sep 15 18:12:09 2026 +0800
branch-incremental-computation: pick the merged incremental-computation PRs
from master in merge order (#67753 #67802 #67814 #67837 #67853 #67876) (#68017)
Cherry-picked from #67753, #67802, #67814, #67837, #67853, #67876
Batch pick of every merged PR carrying the `incremental-computation`
label that `branch-incremental-computation` does not have yet (no
`incremental-computation-picked` label), in the order they landed on
master (`git log --first-parent`). One commit per PR, each created with
`git cherry-pick -x` so the message ends with `(cherry picked from
commit <master sha>)`. Follows the same convention as #67830.
| # | Master commit | PR | Title |
|---|---|---|---|
| 1 | fe39f5b6a42 | #67753 | [fix](ivm) Answer FE-computable dry runs on
the frontend instead of a placeholder backend |
| 2 | f8ed33fa701 | #67802 | [fix](ivm) Refresh the surviving partitions
after an IVM baseline rebuild |
| 3 | 7bd89a0795c | #67814 | [fix](ivm) Stop the incremental delta from
reading partitions the MV dropped |
| 4 | 3050a9ae8ae | #67837 | [fix](ivm) Invalidate the baseline when a
column used by the MV is dropped |
| 5 | 22c95eb8d5c | #67853 | [fix](ivm) Carry the row-binlog hidden
columns in the analyzed MTMV schema |
| 6 | 3390a7a721f | #67876 | [test](ivm) Remove unnecessary cloud skips
from IVM suites |
Not included on purpose:
- The 11 labelled PRs that already carry
`incremental-computation-picked` (#62606 in the fork point, #67508 via
#67712, the nine of #67830).
- #67820 is still open on master; this branch already carries its
content via #67861.
### Prerequisite check
None of the six PRs declares a prerequisite, and none of them needs
another master PR for its behavior. The only master commits that touch
the same files and are not on this branch are unrelated to incremental
computation (#66761 TIMESTAMP_NS, #67545 DLF, #67569 / #67520 / #67835
MySQL-protocol and session refactors, #67186 Hive partition batching,
#67787 SQL cache user variables); they were left out, and two picks
needed a mechanical adaptation because of that:
- **#67753** conflicted in `StmtExecutor.sendMetaData`: master had
already extracted the post-metadata EOF into
`sendMetadataTerminatorIfNeeded(channel)` (#67520, a Connector/J
cursor-fetch fix). The branch keeps its inline EOF block and now sends
it on the given `channel` instead of `context.getMysqlChannel()`, which
is exactly what the extracted helper does on master. Everything else in
the pick is identical to the master commit.
- **#67814** applied cleanly but did not compile: the new
`MTMVPartitionUtil.generateRelatedBasePartitionIds()` returns an
`Optional`, and on master `import java.util.Optional;` came with #67186.
The import was added to the pick commit; that is the only difference
from the master commit.
The other four picks applied without conflicts and are byte-identical to
their master commits (diffs compared with `index`/`@@` lines stripped).
Both adaptations are recorded in the respective commit messages.
### Drift check against master
After the six picks, every touched file is byte-identical to master at
`3390a7a721f` except `MTMV.java`, `MTMVTask.java`,
`MTMVPartitionUtil.java`, `MTMVPartitionUtilTest.java`,
`MTMVTaskTest.java` (differences = #67186 + #67545 + #66761),
`CreateTableInfo.java` (= #67787) and `StmtExecutor.java` (= #67520 +
#67569 + #66761 + the later session refactors #67835 / #67883 + this
branch's #67861). For the first six files, applying those unrelated
master commits on top of the branch's versions reproduces master's files
exactly; for `StmtExecutor.java`, the diff against master right after
#67753 (`fe39f5b6a42`) consists only of #67520 / #67569 / #66761 /
#67861 hunks. So nothing IVM-related is missing. The regression
framework, plugins and the whole `mtmv_p0/ivm` suite/data directories
are identical to master.
### Verification
- FE: `run-fe-ut.sh --run` on this branch (regenerates thrift, compiles
fe-core main + test) with the 17 test classes touched by the picks or
extending the touched `IvmDeltaTestBase`:
17 classes, 404 tests, 0 failures, 0 errors, BUILD SUCCESS (5:19 min) —
`MTMVPlanUtilTest` 24, `IvmAggDeltaHandlerTest` 33,
`IvmDeltaRewriteHelperTest` 17, `IvmNormalizeMTMVJoinTest` 44,
`IvmJoinDeltaHandlerTest` 23, `IvmDeltaRewriteStateTest` 10,
`IvmPlanSignatureGeneratorTest` 22, `IvmBaselineRebuildTest` 28,
`IvmLinearDeltaHandlerTest` 39, `IvmDeltaRewriterTest` 23,
`IvmNormalizeMTMVUnionTest` 10, `MTMVTaskTest` 50,
`MTMVPropertyUtilTest` 13, `MTMVPartitionUtilTest` 16,
`SchemaChangeHandlerTest` 22, `StmtExecutorInternalQueryTest` 3,
`StmtExecutorTest` 27.
- FE checkstyle on fe-core: 0 violations.
- No BE, cloud or thrift changes in this batch.
- All 18 touched groovy files (framework `Suite.groovy`,
`plugin_planner.groovy`, 16 suites) parse cleanly (groovy parser check).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: yujun <[email protected]>
---
.../main/java/org/apache/doris/catalog/MTMV.java | 35 ++++
.../apache/doris/job/extensions/mtmv/MTMVTask.java | 56 ++++--
.../org/apache/doris/mtmv/MTMVPartitionUtil.java | 58 ++++++
.../java/org/apache/doris/mtmv/MTMVPlanUtil.java | 9 +
.../org/apache/doris/mtmv/MTMVPropertyUtil.java | 13 ++
.../org/apache/doris/mtmv/MTMVRelationManager.java | 69 +++++++-
.../apache/doris/mtmv/ivm/IvmDeltaRewriter.java | 64 ++++++-
.../doris/mtmv/ivm/IvmIncrRefreshManager.java | 16 +-
.../apache/doris/mtmv/ivm/IvmRewriteContext.java | 51 +++++-
.../trees/plans/commands/info/CreateTableInfo.java | 28 ++-
.../java/org/apache/doris/qe/StmtExecutor.java | 71 +++++++-
.../doris/alter/SchemaChangeHandlerTest.java | 8 +
.../apache/doris/mtmv/MTMVPartitionUtilTest.java | 23 +++
.../org/apache/doris/mtmv/MTMVPlanUtilTest.java | 64 +++++++
.../apache/doris/mtmv/MTMVPropertyUtilTest.java | 13 ++
.../java/org/apache/doris/mtmv/MTMVTaskTest.java | 42 ++++-
.../doris/mtmv/ivm/IvmBaselineRebuildTest.java | 36 ++++
.../doris/mtmv/ivm/IvmDeltaRewriterTest.java | 73 ++++++++
.../apache/doris/mtmv/ivm/IvmDeltaTestBase.java | 21 ++-
...ivm_drop_referenced_column_baseline_rebuild.out | 8 +
...est_ivm_partition_baseline_rebuild_dup_keys.out | 32 ++++
.../ivm/test_ivm_partition_drop_live_delta.out | 45 +++++
.../mtmv_p0/ivm/test_ivm_partition_sync_limit.out | 14 ++
.../test_ivm_partition_sync_limit_with_window.out | 15 ++
.../data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out | 12 +-
.../ivm/test_ivm_row_binlog_schema_validation.out | 27 +++
.../org/apache/doris/regression/suite/Suite.groovy | 12 +-
regression-test/plugins/plugin_planner.groovy | 21 +++
.../ivm/test_ivm_bitmap_runtime_fallback.groovy | 2 +-
.../test_ivm_drop_column_fallback_reason.groovy | 6 +-
..._drop_referenced_column_baseline_rebuild.groovy | 144 +++++++++++++++
.../test_ivm_fallback_stream_multi_batch.groovy | 4 -
...test_ivm_fallback_stream_multi_batch_dup.groovy | 4 -
.../ivm/test_ivm_minmax_runtime_fallback.groovy | 2 +-
.../mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy | 6 +-
.../ivm/test_ivm_partition_baseline_rebuild.groovy | 2 +-
..._ivm_partition_baseline_rebuild_dup_keys.groovy | 123 +++++++++++++
.../ivm/test_ivm_partition_drop_live_delta.groovy | 134 ++++++++++++++
.../ivm/test_ivm_partition_sync_limit.groovy | 159 +++++++++++++++++
...est_ivm_partition_sync_limit_with_window.groovy | 157 +++++++++++++++++
.../mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy | 29 +--
.../mtmv_p0/ivm/test_ivm_rewrite_projection.groovy | 2 +-
.../test_ivm_row_binlog_schema_validation.groovy | 194 +++++++++++++++++++++
...t_ivm_strict_failure_partition_atomicity.groovy | 2 +-
44 files changed, 1811 insertions(+), 95 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
index 40dba304805..f6047d18a48 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java
@@ -643,6 +643,41 @@ public class MTMV extends OlapTable {
editLogItem.await();
}
+ /**
+ * Release the IVM baseline barrier after the partitions it named have
been rebuilt, or after
+ * partition sync removed them (a dropped partition resolves its own
entry: the partition and its
+ * IVM offsets are both gone).
+ *
+ * <p>Guarded by schemaChangeVersion, like {@link
#persistIvmBaselineGuard}: a base-table change
+ * landing while the rebuild runs carries its own barrier entry, and a
blind clear would swallow
+ * it. Failing instead preserves that entry -- the next refresh rebuilds
it together with the
+ * partitions this task handled.
+ *
+ * <p>Journals the new state right away, like every other ivmInfo mutation
here. A task that dies
+ * before {@link #addTaskResult} would otherwise leave the release in
memory only, and a restart
+ * would resurrect the barrier from disk.
+ */
+ public void releaseIvmBaselineRebuild(long expectedSchemaChangeVersion)
throws JobException {
+ EditLogItem editLogItem;
+ writeMvLock();
+ try {
+ if (ivmInfo == null || !ivmInfo.isBaselineRebuildRequired()) {
+ // Nothing to release: skip both the mutation and the journal
entry. Any base-table
+ // change that raced us in is still caught by
validateIvmRefreshStart() below.
+ return;
+ }
+ if (schemaChangeVersion != expectedSchemaChangeVersion) {
+ throw new JobException("Base table metadata changed before IVM
baseline refresh, mv="
+ + getName());
+ }
+ ivmInfo.clearBaselineRebuild();
+ editLogItem = submitIvmInfoChange();
+ } finally {
+ writeMvUnlock();
+ }
+ editLogItem.await();
+ }
+
public void persistIvmBaselineGuard(RefreshMode refreshMode, Set<String>
baselinePartitions,
long expectedSchemaChangeVersion) throws JobException {
EditLogItem editLogItem;
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 f41ba369fe8..0eaa967cce9 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
@@ -319,9 +319,7 @@ public class MTMVTask extends AbstractTask {
throw new JobException(e.getMessage(), e);
}
MTMVRefreshContext refreshContext = buildRefreshContext(tableIfs);
- if (handlePendingIvmBaselineRebuild(refreshContext, request, ctx))
{
- return;
- }
+ handlePendingIvmBaselineRebuild(refreshContext, request, ctx,
attempts);
boolean disablePartitionRefresh = false;
for (RefreshAttemptType attemptType : attempts) {
switch (attemptType) {
@@ -558,28 +556,56 @@ public class MTMVTask extends AbstractTask {
executePartitionBasedRefresh(context, RefreshMode.COMPLETE, ctx);
}
- private boolean handlePendingIvmBaselineRebuild(MTMVRefreshContext
context, RefreshRequest request,
- ConnectContext ctx)
+ /**
+ * Rebuild the MV partitions whose IVM baseline is broken, before the
normal refresh runs.
+ *
+ * <p>This is a pre-step, not a terminal branch: the caller keeps running
{@code attempts}
+ * afterwards, so a broken baseline no longer skips the refresh entirely.
The list is rewritten
+ * in place when the baseline demands a different set of attempts.
+ *
+ * <p>Partition sync drops the MV partitions whose base partition
disappeared, which is exactly
+ * what the barrier recorded when that base partition was dropped. Those
partitions are resolved
+ * by the drop itself (the partition and its IVM offsets are both gone),
so only the partitions
+ * that still exist need a rebuild. The barrier is released either way,
otherwise the IVM attempt
+ * that follows would be rejected by {@link MTMV#validateIvmRefreshStart}.
+ */
+ private void handlePendingIvmBaselineRebuild(MTMVRefreshContext context,
+ RefreshRequest request, ConnectContext ctx,
List<RefreshAttemptType> attempts)
throws JobException, AnalysisException {
if (!mtmv.isIvm() || request.refreshMode == RefreshMode.COMPLETE
|| !mtmv.getIvmInfo().isBaselineRebuildRequired()) {
- return false;
+ return;
}
ivmFallbackReason = IvmFailureReason.BINLOG_BROKEN.name();
IvmInfo ivmInfo = mtmv.getIvmInfo();
+ // A lone COMPLETE attempt rebuilds every partition anyway, so a
partial pre-rebuild here
+ // would be redundant; it also releases the barrier by itself once it
succeeds.
+ if (attempts.size() == 1 && attempts.get(0) ==
RefreshAttemptType.COMPLETE) {
+ LOG.info("IVM baseline barrier is covered by the pending COMPLETE
attempt, mv={}, taskId={}",
+ mtmv.getName(), getTaskId());
+ return;
+ }
if (ivmInfo.requiresCompleteBaselineRebuild()) {
- executeCompleteAttempt(context, ctx);
- return true;
+ LOG.warn("IVM baseline requires a complete rebuild, mv={},
taskId={}. "
+ + "Continuing with COMPLETE refresh.", mtmv.getName(),
getTaskId());
+ attempts.clear();
+ attempts.add(RefreshAttemptType.COMPLETE);
+ return;
}
- this.needRefreshPartitions = Lists.newArrayList(Sets.intersection(
+ List<String> baselinePartitions = Lists.newArrayList(Sets.intersection(
ivmInfo.getPendingBaselineRebuildPartitions(),
mtmv.getPartitionNames()));
- this.needRefreshPartitions.sort(String::compareTo);
- this.refreshMode = generateRefreshMode(needRefreshPartitions);
- if (refreshMode == MTMVTaskRefreshMode.NOT_REFRESH) {
- return true;
+ if (baselinePartitions.isEmpty()) {
+ // Partition sync has already dropped every partition the barrier
named, so there is
+ // nothing left to rebuild. The surviving partitions are picked up
by the attempts below.
+ LOG.info("IVM baseline partitions were removed by partition sync,
mv={}, taskId={}",
+ mtmv.getName(), getTaskId());
+ } else {
+ baselinePartitions.sort(String::compareTo);
+ this.needRefreshPartitions = baselinePartitions;
+ this.refreshMode = generateRefreshMode(baselinePartitions);
+ executePartitionBasedRefresh(context, RefreshMode.PARTITIONS, ctx);
}
- executePartitionBasedRefresh(context, RefreshMode.PARTITIONS, ctx);
- return true;
+ mtmv.releaseIvmBaselineRebuild(mtmvSchemaChangeVersion);
}
private void validateIvmBaselineBeforePartitionSync(RefreshRequest
request) throws JobException {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
index 700ab47fa44..4505534a73d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPartitionUtil.java
@@ -59,6 +59,7 @@ import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
+import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -211,6 +212,63 @@ public class MTMVPartitionUtil {
return result.getRes();
}
+ /**
+ * Base partitions the MV's partition definition keeps, per base table
partitioned by the MV's
+ * partition column. This is the partition set the MV is aligned to, so it
still contains a base
+ * partition whose MV partition the next {@link #alignMvPartition} has yet
to add, and no longer
+ * contains one the partition properties filter out, such as an expired
partition_sync_limit.
+ *
+ * <p>Empty when no restriction applies at all: without {@code
partition_sync_limit} the MV
+ * mirrors every base partition, so there is nothing to restrict. When
present, the map holds an
+ * entry for every base table the MV partition column comes from, each
with the partitions that
+ * table may be read from — an empty set meaning that table may not be
read at all. Keeping the
+ * empty set explicit is what separates "this table is restricted to
nothing" from "this table
+ * is not one of the MV partition column's sources".
+ *
+ * <p>Read without pinned snapshots, like {@link #alignMvPartition}. A
base partition that
+ * partition sync adds or drops at the same moment can therefore sit
outside the returned set
+ * for one refresh; the same refresh either recovers the partition by
syncing again or leaves
+ * its binlog pending for the next one.
+ *
+ * @return baseTableInfo ==> base partition ids, empty when no restriction
applies
+ */
+ public static Optional<Map<BaseTableInfo, Set<Long>>>
generateRelatedBasePartitionIds(MTMV mtmv)
+ throws AnalysisException {
+ MTMVPartitionInfo mvPartitionInfo = mtmv.getMvPartitionInfo();
+ if (mvPartitionInfo == null
+ || mvPartitionInfo.getPartitionType() ==
MTMVPartitionType.SELF_MANAGE
+ ||
!MTMVPropertyUtil.hasPartitionSyncLimit(mtmv.getMvProperties())) {
+ return Optional.empty();
+ }
+ Map<BaseTableInfo, Set<Long>> res = Maps.newHashMap();
+ // Only olap tables are restricted: the delta rewrite reads them
through their stream and
+ // selects partitions by id, which an external table has no equivalent
of. A connector
+ // table therefore stays out of the scope and keeps its full read, so
supporting one as an
+ // IVM base table means giving the scope a partition identity it can
express, not just
+ // widening these types.
+ for (MTMVRelatedTableIf pctTable : mvPartitionInfo.getPctTables()) {
+ if (pctTable instanceof OlapTable) {
+ res.put(new BaseTableInfo((OlapTable) pctTable),
Sets.newHashSet());
+ }
+ }
+ Map<PartitionKeyDesc, Map<MTMVRelatedTableIf, Set<String>>>
relatedDescs = generateRelatedPartitionDescs(
+ mvPartitionInfo, mtmv.getMvProperties(),
mtmv.getPartitionColumns(), Maps.newHashMap());
+ for (Map<MTMVRelatedTableIf, Set<String>> relatedPartitions :
relatedDescs.values()) {
+ for (Entry<MTMVRelatedTableIf, Set<String>> entry :
relatedPartitions.entrySet()) {
+ if (!(entry.getKey() instanceof OlapTable)) {
+ continue;
+ }
+ OlapTable baseTable = (OlapTable) entry.getKey();
+ Set<Long> partitionIds = res.computeIfAbsent(
+ new BaseTableInfo(baseTable), key ->
Sets.newHashSet());
+ for (String partitionName : entry.getValue()) {
+
partitionIds.add(baseTable.getPartitionOrAnalysisException(partitionName).getId());
+ }
+ }
+ }
+ return Optional.of(res);
+ }
+
/**
* check if table is sync with all baseTables
*
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
index 4d708208c5b..11f50909ccc 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPlanUtil.java
@@ -23,6 +23,7 @@ import org.apache.doris.analysis.StatementBase;
import org.apache.doris.analysis.ToSqlParams;
import org.apache.doris.analysis.UserIdentity;
import org.apache.doris.catalog.AggregateType;
+import org.apache.doris.catalog.BinlogConfig;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.DatabaseIf;
import org.apache.doris.catalog.DistributionInfo;
@@ -625,6 +626,14 @@ public class MTMVPlanUtil {
properties = CreateTableInfo.addOlapHiddenColumns(
columns, isIvm ? KeysType.UNIQUE_KEYS : KeysType.DUP_KEYS,
isIvm, properties, false);
+ // A row-binlog table carries hidden columns on top of the OLAP
ones above, added by
+ // InternalCatalog#createOlapTable just before the table is built.
The analyzed list has
+ // to carry them too: an MTMV re-validates its schema against it
whenever a base table
+ // changes (MTMVPlanUtil#checkColumnIfChange), and a column
missing here is
+ // indistinguishable from a real schema change. Idempotent, so the
table still gets one.
+ CreateTableInfo.addRowBinlogHiddenColumns(columns,
+ isIvm ? KeysType.UNIQUE_KEYS : KeysType.DUP_KEYS, isIvm,
+ BinlogConfig.fromProperties(properties));
// analyze column
final boolean finalEnableMergeOnWrite = isIvm;
Set<String> keysSet =
Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
index d1b8a8765b1..ebd4a6f166d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVPropertyUtil.java
@@ -218,6 +218,19 @@ public class MTMVPropertyUtil {
PropertyAnalyzer.PROPERTIES_IVM_PARTITION_WINDOW_LIMIT));
}
+ /**
+ * True when the MV keeps only a recent slice of each base table's
partitions. This is the only
+ * property that can leave the MV without a partition the base table still
has, so it is also
+ * the only case where the incremental delta has to be told which base
partitions it may read.
+ */
+ public static boolean hasPartitionSyncLimit(Map<String, String>
mvProperties) {
+ if (mvProperties == null) {
+ return false;
+ }
+ String value =
mvProperties.get(PropertyAnalyzer.PROPERTIES_PARTITION_SYNC_LIMIT);
+ return !StringUtils.isEmpty(value) && Integer.parseInt(value) > 0;
+ }
+
/**
* Look up the window limit configured for a base table, mirroring the
* excluded_trigger_tables name-matching semantics (empty db/ctl wildcard).
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
index dd2806fd88b..3bfa209fd6f 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java
@@ -86,16 +86,16 @@ public class MTMVRelationManager implements MTMVHookService
{
}
public void markIvmBaselineRebuild(BaseTableInfo baseTableInfo, String
reason) {
- markIvmBaselineRebuild(baseTableInfo, Collections.emptyMap(), reason);
+ markIvmBaselineRebuild(baseTableInfo, true, Collections.emptyMap(),
reason);
}
public void markIvmBaselineRebuildForPartitionChange(BaseTableInfo
baseTableInfo,
Map<String, Long> changedPartitions, String reason) {
Preconditions.checkArgument(!changedPartitions.isEmpty(), "changed
partitions can not be empty");
- markIvmBaselineRebuild(baseTableInfo, changedPartitions, reason);
+ markIvmBaselineRebuild(baseTableInfo, false, changedPartitions,
reason);
}
- private void markIvmBaselineRebuild(BaseTableInfo baseTableInfo,
+ private void markIvmBaselineRebuild(BaseTableInfo baseTableInfo, boolean
allPartitionsChanged,
Map<String, Long> changedPartitions, String reason) {
TableNameInfo baseTableName = new
TableNameInfo(baseTableInfo.getCtlName(),
baseTableInfo.getDbName(), baseTableInfo.getTableName());
@@ -115,7 +115,7 @@ public class MTMVRelationManager implements MTMVHookService
{
if
(MTMVPartitionUtil.isTableExcluded(mtmv.getExcludedTriggerTables(),
baseTableName)) {
continue;
}
- if (changedPartitions.isEmpty()) {
+ if (allPartitionsChanged) {
mtmv.invalidateIvmBaseline();
} else {
mtmv.invalidateIvmBaseline(baseTableInfo, changedPartitions);
@@ -335,7 +335,9 @@ public class MTMVRelationManager implements MTMVHookService
{
*/
@Override
public void dropTable(Table table) {
- processBaseTableChange(new BaseTableInfo(table), "The base table has
been deleted:");
+ // A dropped base table is already caught by the IVM stream guard (the
stream records the
+ // base table id, so it stops being usable once the table is gone), no
need to re-analyze.
+ processBaseTableChange(new BaseTableInfo(table), "The base table has
been deleted:", false);
}
/**
@@ -347,9 +349,56 @@ public class MTMVRelationManager implements
MTMVHookService {
public void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace) {
// when replace, need deal two table
if (isReplace) {
- processBaseTableChange(newTableInfo.get(), "The base table has
been updated:");
+ // REPLACE TABLE already invalidates the IVM baseline explicitly,
see Alter#processReplaceTable
+ processBaseTableChange(newTableInfo.get(), "The base table has
been updated:", false);
+ }
+ // A RENAME leaves every column alone, and the failure it does cause
-- the MV query still
+ // spells the old name -- is already reported by the refresh itself
(MTMVTask#run resolves
+ // the base tables from the query before it ever looks at the
baseline). Invalidating here
+ // would only leave a stale flag behind: rename the table back and the
query is analyzable
+ // again, yet every strict INCREMENTAL refresh would stay rejected
until a COMPLETE one ran.
+ boolean renamed = !isReplace && newTableInfo.isPresent()
+ && !Objects.equals(oldTableInfo.getTableName(),
newTableInfo.get().getTableName());
+ processBaseTableChange(oldTableInfo, "The base table has been
updated:", !renamed);
+ }
+
+ /**
+ * An IVM baseline is only valid while the MV query can still be analyzed
against the current
+ * base table schema. Re-analyzing the MV query here (right after the
alter was applied) is what
+ * detects a changed column identity: dropping or renaming a column the MV
uses makes the query
+ * unanalyzable, and a column re-added with the same name is a different
column, so pre-existing
+ * rows read its default value instead.
+ *
+ * <p>Such a change is metadata-only for light schema changes and emits no
binlog, so an
+ * incremental refresh would consume an empty delta and report SUCCESS
while silently keeping the
+ * rows computed under the old column epoch. Invalidating the baseline
makes a strict INCREMENTAL
+ * refresh fail and tell the user to run a COMPLETE refresh instead.
+ *
+ * <p>Only IVM is covered: a plain MTMV keeps its previous behaviour
(status only).
+ */
+ private void invalidateIvmBaselineIfQueryUnusable(BaseTableInfo
baseTableInfo, Table mtmvTable) {
+ if (!(mtmvTable instanceof MTMV) || !((MTMV) mtmvTable).isIvm()) {
+ return;
+ }
+ MTMV mtmv = (MTMV) mtmvTable;
+ // Analyse in a context owned by this check, never the session that
issued the alter: the check
+ // must not disturb the running statement, and it has to work on
threads that have no session.
+ // Setting a thread local is how a context is made current, so restore
the previous one.
+ ConnectContext previousCtx = ConnectContext.get();
+ try {
+ MTMVPlanUtil.ensureMTMVQueryUsable(mtmv,
+ MTMVPlanUtil.createMTMVContext(mtmv,
MTMVPlanUtil.DISABLE_RULES_WHEN_RUN_MTMV_TASK));
+ } catch (Exception e) {
+ LOG.info("Invalidate IVM baseline, the MV query is no longer
usable. baseTable={}, mtmv={}, "
+ + "reason={}", baseTableInfo, mtmv.getName(),
e.getMessage());
+ mtmv.invalidateIvmBaseline();
+ } finally {
+ if (previousCtx != null) {
+ previousCtx.setThreadLocalInfo();
+ } else {
+ ConnectContext.remove();
+ }
}
- processBaseTableChange(oldTableInfo, "The base table has been
updated:");
}
@Override
@@ -411,7 +460,8 @@ public class MTMVRelationManager implements MTMVHookService
{
}
}
- private void processBaseTableChange(BaseTableInfo baseTableInfo, String
msgPrefix) {
+ private void processBaseTableChange(BaseTableInfo baseTableInfo, String
msgPrefix,
+ boolean checkIvmQueryUsable) {
Set<BaseTableInfo> mtmvsByBaseTable =
getMtmvsByBaseTableOneLevelAndFromView(baseTableInfo);
if (CollectionUtils.isEmpty(mtmvsByBaseTable)) {
return;
@@ -424,6 +474,9 @@ public class MTMVRelationManager implements MTMVHookService
{
LOG.warn(e);
continue;
}
+ if (checkIvmQueryUsable) {
+ invalidateIvmBaselineIfQueryUnusable(baseTableInfo, mtmv);
+ }
TableNameInfo tableNameInfo = new
TableNameInfo(mtmv.getQualifiedDbName(),
mtmv.getName());
MTMVStatus status = new MTMVStatus(MTMVState.SCHEMA_CHANGE,
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
index a6894a64149..1d97e87796c 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriter.java
@@ -26,6 +26,7 @@ import org.apache.doris.catalog.stream.OlapTableStream;
import org.apache.doris.common.Config;
import org.apache.doris.common.Pair;
import org.apache.doris.info.TableNameInfoUtils;
+import org.apache.doris.mtmv.BaseTableInfo;
import org.apache.doris.mtmv.MTMVPartitionUtil;
import org.apache.doris.mtmv.MTMVPropertyUtil;
import org.apache.doris.nereids.trees.expressions.Slot;
@@ -37,6 +38,7 @@ import
org.apache.doris.nereids.trees.plans.logical.LogicalProject;
import org.apache.doris.nereids.types.DataType;
import org.apache.doris.qe.ConnectContext;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
@@ -66,7 +68,8 @@ public class IvmDeltaRewriter {
Pair<Plan, List<LogicalProject<?>>> prefixChain =
helper.detachAdaptProjectChain(sinkChild);
Plan rootPlan = prefixChain.first;
long refreshVersion = refreshContext.getMtmv().getNextRefreshVersion();
- IvmDeltaRewriteState rewriteState = createDeltaRewriteState(rootPlan,
refreshContext, refreshVersion);
+ IvmDeltaRewriteState rewriteState = createDeltaRewriteState(rootPlan,
refreshContext, refreshVersion,
+ rewriteContext.getIncrementalScopePartitionIds());
Optional<IvmDeltaRewriteResult> deltaResult = rewriteDelta(rootPlan,
refreshContext, rewriteState);
if (!deltaResult.isPresent()) {
return new LogicalEmptyRelation(
@@ -153,7 +156,8 @@ public class IvmDeltaRewriter {
return IvmDeltaRewriteHelper.INSTANCE.freshPlan(rewritten);
}
- private IvmDeltaRewriteState createDeltaRewriteState(Plan plan,
IvmIncrRefreshContext ctx, long refreshVersion) {
+ private IvmDeltaRewriteState createDeltaRewriteState(Plan plan,
IvmIncrRefreshContext ctx, long refreshVersion,
+ Map<BaseTableInfo, Set<Long>> scopePartitionIds) {
Map<OlapTable, OlapTableStream> streams = new HashMap<>();
// Window limits apply to every base table in the plan, including
excluded
// trigger tables (their snapshot side is windowed too, so the
property saves
@@ -181,11 +185,67 @@ public class IvmDeltaRewriter {
new TableNameInfo(table.getFullQualifiers()),
windowLimits));
}
}
+ applyScopePartitionIds(windowPartitionIdsByTable, planTables,
scopePartitionIds);
return new IvmDeltaRewriteState(streams,
ctx.isIncludeExhaustedStreams(), refreshVersion,
DataType.fromCatalogType(ctx.getMtmv().getColumn(Column.SEQUENCE_COL).getType()),
windowPartitionIdsByTable);
}
+ /**
+ * Limits every base table whose partition column feeds the MV's partition
column to the base
+ * partitions the MV's partition definition keeps. The delta and the
join-opposite snapshot
+ * would otherwise also read base partitions the MV does not keep, expired
by
+ * partition_sync_limit, and then try to write their rows into MV
partitions that do not exist,
+ * which fails the whole insert with "no partition for this tuple" and
takes the partitions
+ * that do exist down with it.
+ *
+ * <p>The limit is the partition set the MV is aligned to rather than the
partitions it already
+ * has, so a base partition whose MV partition has not been added yet
stays readable: the
+ * refresh still reports the missing partition and recovers it by syncing.
+ *
+ * <p>Tables outside {@code scopePartitionIds} keep their full read: the
MV partition column
+ * does not come from them, so limiting them would change join results
without narrowing the
+ * set of MV partitions the delta can target. A scope that covers every
partition of its table
+ * is left alone as well, so an MV that mirrors all of its base partitions
keeps the plan it
+ * had before this restriction existed. An entry with no partitions is the
opposite case and is
+ * applied as it stands: the scan then reads nothing, which is how the
delta and the snapshot
+ * side both stay out of a table the MV has no partition for.
+ *
+ * <p>The scope is keyed by base table identity, but only olap tables are
reachable here: the
+ * delta reads them through {@code OlapTableStream} and restricts a scan
by partition id.
+ */
+ private static void applyScopePartitionIds(Map<OlapTable, List<Long>>
windowPartitionIdsByTable,
+ Set<OlapTable> planTables, Map<BaseTableInfo, Set<Long>>
scopePartitionIds) {
+ if (scopePartitionIds.isEmpty()) {
+ return;
+ }
+ for (OlapTable table : planTables) {
+ Set<Long> tableScope = scopePartitionIds.get(new
BaseTableInfo(table));
+ if (tableScope == null ||
tableScope.containsAll(table.getPartitionIds())) {
+ continue;
+ }
+ List<Long> windowPartitionIds =
windowPartitionIdsByTable.get(table);
+ List<Long> readablePartitionIds;
+ if (windowPartitionIds == null) {
+ readablePartitionIds = new ArrayList<>(tableScope);
+ } else {
+ // Both limits apply, so only their intersection is readable.
One keeps the
+ // partitions above a time cutoff and the other the last N by
partition value, so
+ // each is a suffix of the same value order and the two cannot
be disjoint.
+ readablePartitionIds = new ArrayList<>();
+ for (Long partitionId : windowPartitionIds) {
+ if (tableScope.contains(partitionId)) {
+ readablePartitionIds.add(partitionId);
+ }
+ }
+ }
+ // Sorted like every other partition selection handed to a scan,
so the plan shape does
+ // not depend on the order the two limits were combined in.
+ readablePartitionIds.sort(Long::compareTo);
+ windowPartitionIdsByTable.put(table, readablePartitionIds);
+ }
+ }
+
boolean isExcludedTriggerTable(LogicalOlapScan scan, Set<TableNameInfo>
excludedTriggerTables) {
if (excludedTriggerTables == null || excludedTriggerTables.isEmpty()) {
return false;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManager.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManager.java
index 886d6ee6c23..4559fd54937 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManager.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmIncrRefreshManager.java
@@ -22,6 +22,8 @@ import org.apache.doris.common.util.DebugPointUtil;
import org.apache.doris.common.util.DebugUtil;
import org.apache.doris.common.util.Util;
import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.mtmv.BaseTableInfo;
+import org.apache.doris.mtmv.MTMVPartitionUtil;
import org.apache.doris.mtmv.MTMVPlanUtil;
import org.apache.doris.nereids.StatementContext;
import org.apache.doris.nereids.analyzer.UnboundTableSink;
@@ -39,9 +41,12 @@ import com.google.common.collect.ImmutableList;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import java.util.Collections;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.Set;
/**
* Minimal orchestration entry point for incremental refresh.
@@ -92,7 +97,16 @@ public class IvmIncrRefreshManager {
MTMV mtmv = context.getMtmv();
StatementContext statementContext = new StatementContext(
context.getConnectContext(), new
OriginStatement(mtmv.getQuerySql(), 0));
-
statementContext.setIvmRewriteContext(Optional.of(IvmRewriteContext.incremental(mtmv)));
+ // The delta may only read the base partitions the MV's partition
definition keeps. A base
+ // partition outside that set, expired by partition_sync_limit, would
otherwise still be
+ // read through the delta and the join-opposite snapshot, and its rows
would have no MV
+ // partition to land in. A base partition inside the set stays
readable even when its MV
+ // partition is not there yet, so that the refresh still reports the
missing partition and
+ // recovers it by syncing.
+ Map<BaseTableInfo, Set<Long>> scopePartitionIds =
+
MTMVPartitionUtil.generateRelatedBasePartitionIds(mtmv).orElse(Collections.emptyMap());
+ statementContext.setIvmRewriteContext(
+ Optional.of(IvmRewriteContext.incremental(mtmv,
scopePartitionIds)));
// Excluded trigger tables do not produce delta and must not be
validated for
// binlog / key-type support during the incremental analyze.
statementContext.setExcludedTriggerTables(mtmv.getExcludedTriggerTables());
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRewriteContext.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRewriteContext.java
index 414a1df3d51..a6fefda42c9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRewriteContext.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/ivm/IvmRewriteContext.java
@@ -76,6 +76,11 @@ public class IvmRewriteContext {
private final Optional<IvmDryRunLimit> dryRunLimit;
private final Map<BaseTableInfo, Set<Long>> fullRefreshResetPartitionIds;
private final Optional<StreamReadMode> fullRefreshNonPctReadMode;
+ // Base partitions the incremental delta may read, per base table. A table
that is absent is
+ // read in full, so an empty map keeps the unrestricted behaviour. Unlike
+ // fullRefreshResetPartitionIds, which names the partitions a COMPLETE
refresh must reset,
+ // every entry here is an upper bound on what the delta is allowed to read.
+ private final Map<BaseTableInfo, Set<Long>> incrementalScopePartitionIds;
// Set by MTMVPlanUtil before normalization: true means the MV unique keys
include identity key columns.
// Null when the rewrite context is created outside the analyzeQuery flow.
private Boolean useFullKeys;
@@ -83,7 +88,8 @@ public class IvmRewriteContext {
private IvmRewriteContext(Mode mode, MTMV mtmv, String createMtmvName,
boolean includeExhaustedStreams,
ExecutionKind executionKind, Optional<IvmDryRunLimit> dryRunLimit,
Map<BaseTableInfo, Set<Long>> fullRefreshResetPartitionIds,
- Optional<StreamReadMode> fullRefreshNonPctReadMode) {
+ Optional<StreamReadMode> fullRefreshNonPctReadMode,
+ Map<BaseTableInfo, Set<Long>> incrementalScopePartitionIds) {
this.mode = Objects.requireNonNull(mode, "mode can not be null");
this.mtmv = mode == Mode.CREATE ? mtmv : Objects.requireNonNull(mtmv,
"mtmv can not be null");
this.createMtmvName = createMtmvName;
@@ -97,45 +103,66 @@ public class IvmRewriteContext {
this.fullRefreshResetPartitionIds =
Collections.unmodifiableMap(resetPartitionIds);
this.fullRefreshNonPctReadMode = Objects.requireNonNull(
fullRefreshNonPctReadMode, "fullRefreshNonPctReadMode can not
be null");
+ Map<BaseTableInfo, Set<Long>> scopePartitionIds = new HashMap<>();
+ Objects.requireNonNull(incrementalScopePartitionIds,
"incrementalScopePartitionIds can not be null")
+ .forEach((baseTableInfo, partitionIds) ->
scopePartitionIds.put(baseTableInfo,
+ Collections.unmodifiableSet(new
HashSet<>(partitionIds))));
+ this.incrementalScopePartitionIds =
Collections.unmodifiableMap(scopePartitionIds);
}
public static IvmRewriteContext create(String mtmvName) {
return new IvmRewriteContext(Mode.CREATE, null,
Objects.requireNonNull(mtmvName, "mtmvName can not be null"),
false,
- ExecutionKind.EXECUTE, Optional.empty(),
Collections.emptyMap(), Optional.empty());
+ ExecutionKind.EXECUTE, Optional.empty(),
Collections.emptyMap(), Optional.empty(),
+ Collections.emptyMap());
}
public static IvmRewriteContext normalize(MTMV mtmv) {
return new IvmRewriteContext(Mode.NORMALIZE,
Objects.requireNonNull(mtmv, "mtmv can not be null"),
- null, false, ExecutionKind.EXECUTE, Optional.empty(),
Collections.emptyMap(), Optional.empty());
+ null, false, ExecutionKind.EXECUTE, Optional.empty(),
Collections.emptyMap(), Optional.empty(),
+ Collections.emptyMap());
}
public static IvmRewriteContext incremental(MTMV mtmv) {
+ return incremental(mtmv, Collections.emptyMap());
+ }
+
+ /**
+ * Incremental refresh whose delta may only read the given base
partitions. A base partition
+ * outside the scope is read neither through its stream nor through the
join-opposite
+ * snapshot, so a base partition the MV no longer mirrors cannot produce
delta rows that the
+ * MV has no target partition for.
+ */
+ public static IvmRewriteContext incremental(MTMV mtmv, Map<BaseTableInfo,
Set<Long>> scopePartitionIds) {
return new IvmRewriteContext(Mode.INCREMENTAL,
Objects.requireNonNull(mtmv, "mtmv can not be null"),
- null, false, ExecutionKind.EXECUTE, Optional.empty(),
Collections.emptyMap(), Optional.empty());
+ null, false, ExecutionKind.EXECUTE, Optional.empty(),
Collections.emptyMap(), Optional.empty(),
+ scopePartitionIds);
}
/** EXPLAIN REFRESH INCREMENTAL [ALL]: only a plan is produced. */
public static IvmRewriteContext incrementalExplain(MTMV mtmv, boolean
includeExhaustedStreams) {
return new IvmRewriteContext(Mode.INCREMENTAL,
Objects.requireNonNull(mtmv, "mtmv can not be null"),
null, includeExhaustedStreams, ExecutionKind.EXPLAIN,
- Optional.empty(), Collections.emptyMap(), Optional.empty());
+ Optional.empty(), Collections.emptyMap(), Optional.empty(),
Collections.emptyMap());
}
public static IvmRewriteContext incrementalDryRun(MTMV mtmv,
Optional<IvmDryRunLimit> dryRunLimit) {
return new IvmRewriteContext(Mode.INCREMENTAL, mtmv, null, false,
- ExecutionKind.DRY_RUN, dryRunLimit, Collections.emptyMap(),
Optional.empty());
+ ExecutionKind.DRY_RUN, dryRunLimit, Collections.emptyMap(),
Optional.empty(),
+ Collections.emptyMap());
}
/** EXPLAIN REFRESH COMPLETE: only a plan is produced. */
public static IvmRewriteContext fullExplain(MTMV mtmv) {
return new IvmRewriteContext(Mode.FULL, Objects.requireNonNull(mtmv,
"mtmv can not be null"),
- null, false, ExecutionKind.EXPLAIN, Optional.empty(),
Collections.emptyMap(), Optional.empty());
+ null, false, ExecutionKind.EXPLAIN, Optional.empty(),
Collections.emptyMap(), Optional.empty(),
+ Collections.emptyMap());
}
public static IvmRewriteContext full(MTMV mtmv) {
return new IvmRewriteContext(Mode.FULL, Objects.requireNonNull(mtmv,
"mtmv can not be null"),
- null, false, ExecutionKind.EXECUTE, Optional.empty(),
Collections.emptyMap(), Optional.empty());
+ null, false, ExecutionKind.EXECUTE, Optional.empty(),
Collections.emptyMap(), Optional.empty(),
+ Collections.emptyMap());
}
public static IvmRewriteContext full(MTMV mtmv,
@@ -143,7 +170,8 @@ public class IvmRewriteContext {
StreamReadMode nonPctReadMode) {
return new IvmRewriteContext(Mode.FULL, mtmv, null, false,
ExecutionKind.EXECUTE, Optional.empty(),
resetPartitionIds,
- Optional.of(Objects.requireNonNull(nonPctReadMode,
"nonPctReadMode can not be null")));
+ Optional.of(Objects.requireNonNull(nonPctReadMode,
"nonPctReadMode can not be null")),
+ Collections.emptyMap());
}
public Mode getMode() {
@@ -189,6 +217,11 @@ public class IvmRewriteContext {
return
Optional.ofNullable(fullRefreshResetPartitionIds.get(baseTableInfo)).map(HashSet::new);
}
+ /** Empty when the incremental delta is not limited to a partition subset.
*/
+ public Map<BaseTableInfo, Set<Long>> getIncrementalScopePartitionIds() {
+ return incrementalScopePartitionIds;
+ }
+
public Optional<StreamReadMode> getFullRefreshNonPctReadMode() {
return fullRefreshNonPctReadMode;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java
index ac39d7256a7..c23a95b85c6 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/CreateTableInfo.java
@@ -1646,14 +1646,34 @@ public class CreateTableInfo {
* Add hidden columns required by row binlog.
*/
public void createRowBinlogHiddenColumnsIfNecessary(BinlogConfig
binlogConfig) {
- if (!binlogConfig.isRowFormat()) {
+ addRowBinlogHiddenColumns(columns, keysType, isEnableMergeOnWrite,
binlogConfig);
+ }
+
+ /**
+ * Append the hidden columns a row-binlog table carries. Callers that
build the column list
+ * outside the create-table flow (an analyzed MTMV schema) go through here
as well, so both
+ * sides of {@code MTMVPlanUtil#checkColumnIfChange} agree on the physical
layout.
+ *
+ * <p>Idempotent: a column that is already present is kept once.
+ */
+ public static void addRowBinlogHiddenColumns(List<ColumnDefinition>
columns, KeysType keysType,
+ boolean isEnableMergeOnWrite, BinlogConfig binlogConfig) {
+ if (binlogConfig == null || !binlogConfig.isRowFormat()) {
return;
}
if (keysType.equals(KeysType.DUP_KEYS)) {
-
columns.add(ColumnDefinition.newCommitTsoColumnDefinition(AggregateType.NONE));
-
columns.add(ColumnDefinition.newRowLsnColumnDefinition(AggregateType.NONE));
+ addIfAbsent(columns,
ColumnDefinition.newCommitTsoColumnDefinition(AggregateType.NONE));
+ addIfAbsent(columns,
ColumnDefinition.newRowLsnColumnDefinition(AggregateType.NONE));
} else if (keysType.equals(KeysType.UNIQUE_KEYS) &&
isEnableMergeOnWrite) {
-
columns.add(ColumnDefinition.newCommitTsoColumnDefinition(AggregateType.NONE));
+ addIfAbsent(columns,
ColumnDefinition.newCommitTsoColumnDefinition(AggregateType.NONE));
+ }
+ }
+
+ private static void addIfAbsent(List<ColumnDefinition> columns,
ColumnDefinition columnDefinition) {
+ boolean present = columns.stream()
+ .anyMatch(column ->
column.getName().equalsIgnoreCase(columnDefinition.getName()));
+ if (!present) {
+ columns.add(columnDefinition);
}
}
}
diff --git a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java
b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java
index a2cbd7ec204..a7a28604c5a 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/qe/StmtExecutor.java
@@ -1853,11 +1853,16 @@ public class StmtExecutor {
}
private void sendMetaData(ResultSetMetaData metaData, List<FieldInfo>
fieldInfos) throws IOException {
+ sendMetaData(metaData, fieldInfos, context.getMysqlChannel());
+ }
+
+ private void sendMetaData(ResultSetMetaData metaData, List<FieldInfo>
fieldInfos, MysqlChannel channel)
+ throws IOException {
Preconditions.checkState(context.getConnectType() ==
ConnectType.MYSQL);
// sends how many columns
serializer.reset();
serializer.writeVInt(metaData.getColumnCount());
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
// send field one by one
for (int i = 0; i < metaData.getColumns().size(); i++) {
Column col = metaData.getColumn(i);
@@ -1868,16 +1873,16 @@ public class StmtExecutor {
} else {
serializer.writeField(fieldInfos.get(i), col.getType());
}
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
}
// When CLIENT_DEPRECATE_EOF is set, the server should not send the
intermediate
// EOF packet after column definitions. The client will go directly
from column
// definitions to reading data rows.
- if (!context.getMysqlChannel().clientDeprecatedEOF()) {
+ if (!channel.clientDeprecatedEOF()) {
serializer.reset();
MysqlEofPacket eofPacket = new MysqlEofPacket(context.getState());
eofPacket.writeTo(serializer);
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
}
}
@@ -2030,19 +2035,34 @@ public class StmtExecutor {
}
public void sendResultSet(ResultSet resultSet, List<FieldInfo> fieldInfos)
throws IOException {
+ sendResultSet(resultSet, fieldInfos, null);
+ }
+
+ /**
+ * Sends a FE-computed result set to the given mysql channel. Regular
queries use the
+ * executor's own channel; internal queries have to stream to the channel
of the caller
+ * that issued them, because the executor's own channel is not connected
to that client.
+ *
+ * <p>A null channel means the session's own. It is resolved inside the
mysql branch on
+ * purpose: a connection of any other type has no mysql channel, and
asking for one throws,
+ * so a caller must be able to hand a result set over without naming a
channel first.
+ */
+ private void sendResultSet(ResultSet resultSet, List<FieldInfo>
fieldInfos, MysqlChannel channel)
+ throws IOException {
if (context.getConnectType().equals(ConnectType.MYSQL)) {
+ MysqlChannel targetChannel = channel == null ?
context.getMysqlChannel() : channel;
context.updateReturnRows(resultSet.getResultRows().size());
// Send meta data.
- sendMetaData(resultSet.getMetaData(), fieldInfos);
+ sendMetaData(resultSet.getMetaData(), fieldInfos, targetChannel);
// Send result set.
if (isComStmtExecute) {
if (LOG.isDebugEnabled()) {
LOG.debug("Use binary protocol to set result.");
}
- sendBinaryResultRow(resultSet);
+ sendBinaryResultRow(resultSet, targetChannel);
} else {
- sendTextResultRow(resultSet);
+ sendTextResultRow(resultSet, targetChannel);
}
context.getState().setEof();
} else if
(context.getConnectType().equals(ConnectType.ARROW_FLIGHT_SQL)) {
@@ -2056,6 +2076,10 @@ public class StmtExecutor {
}
protected void sendTextResultRow(ResultSet resultSet) throws IOException {
+ sendTextResultRow(resultSet, context.getMysqlChannel());
+ }
+
+ protected void sendTextResultRow(ResultSet resultSet, MysqlChannel
channel) throws IOException {
for (List<String> row : resultSet.getResultRows()) {
serializer.reset();
for (String item : row) {
@@ -2065,11 +2089,15 @@ public class StmtExecutor {
serializer.writeLenEncodedString(item);
}
}
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
}
}
protected void sendBinaryResultRow(ResultSet resultSet) throws IOException
{
+ sendBinaryResultRow(resultSet, context.getMysqlChannel());
+ }
+
+ protected void sendBinaryResultRow(ResultSet resultSet, MysqlChannel
channel) throws IOException {
//
https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_binary_resultset.html#sect_protocol_binary_resultset_row_value
ResultSetMetaData metaData = resultSet.getMetaData();
int nullBitmapLength = (metaData.getColumnCount() + 7 + 2) / 8;
@@ -2127,7 +2155,7 @@ public class StmtExecutor {
}
}
}
- context.getMysqlChannel().sendOnePacket(serializer.toByteBuffer());
+ channel.sendOnePacket(serializer.toByteBuffer());
}
}
@@ -2302,6 +2330,31 @@ public class StmtExecutor {
planner = new NereidsPlanner(statementContext);
planner.plan(adapter, context.getSessionVariable().toThrift());
+ // A plan that FE can compute on its own (e.g. the empty delta of
a dry run) must be
+ // answered by the frontend, like a regular query does. Otherwise
the coordinator
+ // would send fragments to the placeholder backend registered when
no backend is
+ // needed (see NereidsPlanner#notNeedBackend), which cannot
resolve. Results go to the
+ // caller's channel: the executor's own channel is not connected
to that client.
+ if (context.supportHandleByFe()) {
+ Optional<ResultSet> resultSet =
planner.handleQueryInFe(adapter);
+ if (resultSet.isPresent()) {
+ boolean sendToChannel = !collectMode;
+ if (sendToChannel) {
+ sendResultSet(resultSet.get(),
adapter.getFieldInfos(), sendChannel);
+ }
+ isHandleQueryInFe = true;
+ if (context.getSessionVariable().enableProfile() &&
profile != null) {
+
profile.getSummaryProfile().setExecutedByFrontend(true);
+ }
+ if (sendToChannel) {
+ return new ArrayList<>();
+ }
+ return resultSet.get().getResultRows().stream()
+ .map(ResultRow::new)
+ .collect(Collectors.toList());
+ }
+ }
+
if (!collectMode) {
executeAndSendResult(false, false, adapter, sendChannel, null,
null);
return new ArrayList<>();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeHandlerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeHandlerTest.java
index 4b3962b9142..1be3cca764a 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeHandlerTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeHandlerTest.java
@@ -422,6 +422,14 @@ public class SchemaChangeHandlerTest extends
TestWithFeService {
createTable(create);
expectException("ALTER TABLE test." + tableName + " MODIFY COLUMN v1
BIGINT", "Table With binlog<row>");
+ // 1b) RENAME COLUMN / REORDER COLUMNS are not allowed on row binlog
tables either.
+ // This matters for IVM: dropping a column an MV references
invalidates the IVM baseline, so
+ // every other way of changing a referenced column has to be rejected
here. If one of them
+ // were ever allowed, it would become a new way to leave an MV stale
without being noticed.
+ expectException("ALTER TABLE test." + tableName + " RENAME COLUMN v1
TO v1_renamed",
+ "Table With binlog<row>");
+ expectException("ALTER TABLE test." + tableName + " ORDER BY (k1,
v1)", "Table With binlog<row>");
+
// 2) VARIANT not supported
String createVariant = "CREATE TABLE test.binlog_variant (k1 INT NOT
NULL, v1 VARIANT) "
+ "UNIQUE KEY(k1) DISTRIBUTED BY HASH(k1) BUCKETS 1 "
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
index bb484c7ce7b..73a9a092357 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPartitionUtilTest.java
@@ -253,6 +253,29 @@ public class MTMVPartitionUtilTest {
Assertions.assertFalse(MTMVPartitionUtil.isTableNamelike(new
TableNameInfo("ctl1"), tableNameToCheck));
}
+ @Test
+ public void testGenerateRelatedBasePartitionIdsWithoutSyncLimit() throws
AnalysisException {
+ // Without partition_sync_limit the MV mirrors every base partition,
so the incremental
+ // delta has nothing to restrict and must be left alone.
+
Mockito.when(mtmvPartitionInfo.getPartitionType()).thenReturn(MTMVPartitionType.FOLLOW_BASE_TABLE);
+ Mockito.when(mtmv.getMvProperties()).thenReturn(Maps.newHashMap());
+
Assertions.assertFalse(MTMVPartitionUtil.generateRelatedBasePartitionIds(mtmv).isPresent());
+ }
+
+ @Test
+ public void testGenerateRelatedBasePartitionIdsOnSelfManageMv() throws
AnalysisException {
+ // setUp leaves the mocked MV on SELF_MANAGE: it decides its own
partitions, so there is no
+ // base partition mapping to restrict the delta to.
+
Assertions.assertFalse(MTMVPartitionUtil.generateRelatedBasePartitionIds(mtmv).isPresent());
+ }
+
+ @Test
+ public void testGenerateRelatedBasePartitionIdsWithoutMvPartitionInfo()
throws AnalysisException {
+ MTMV mvWithoutPartitionInfo = Mockito.mock(MTMV.class);
+ Assertions.assertFalse(
+
MTMVPartitionUtil.generateRelatedBasePartitionIds(mvWithoutPartitionInfo).isPresent());
+ }
+
@Test
public void testGetBaseVersionsUsesMappedPartitions() throws
AnalysisException {
Map<String, Map<MTMVRelatedTableIf, Set<String>>> partitionMappings =
Maps.newHashMap();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPlanUtilTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPlanUtilTest.java
index 162660ed8e5..c2b0f83d44f 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPlanUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPlanUtilTest.java
@@ -490,6 +490,70 @@ public class MTMVPlanUtilTest extends SqlTestBase {
incrementalCtx.getStatementContext().getIvmRewriteContext().orElseThrow().getMode());
}
+ @Test
+ public void testEnsureMTMVQueryUsableWithRowBinlogHiddenColumns() throws
Exception {
+ // A table created with row binlog carries hidden columns its query
never produces:
+ // __DORIS_COMMIT_TSO_COL__ for merge-on-write unique keys, plus
__DORIS_ROW_LSN_COL__ for
+ // duplicate keys. InternalCatalog#createOlapTable adds them while the
table is built, so an
+ // MV that enables row binlog itself ends up with them in its physical
schema.
+ // ensureMTMVQueryUsable re-derives the schema from the query and
compares the two
+ // (checkColumnIfChange), so the analyzed column list has to carry
them too -- otherwise
+ // every refresh of such an MV fails with a spurious "column length
not equals".
+ createTable("CREATE TABLE IF NOT EXISTS row_binlog_schema_base (\n"
+ + " k1 int,\n"
+ + " v1 int\n"
+ + ")\n"
+ + "DUPLICATE KEY(k1)\n"
+ + "DISTRIBUTED BY HASH(k1) BUCKETS 1\n"
+ + "PROPERTIES ('replication_num' = '1', 'binlog.enable' =
'true', 'binlog.format' = 'ROW')\n");
+
+ createMvByNereids("create materialized view row_binlog_schema_ivm "
+ + "BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL\n"
+ + " DISTRIBUTED BY RANDOM BUCKETS 1\n"
+ + " PROPERTIES ('replication_num' = '1',
'binlog.enable' = 'true', "
+ + "'binlog.format' = 'ROW') \n"
+ + " as select k1, v1 from
test.row_binlog_schema_base;");
+ createMvByNereids("create materialized view row_binlog_schema_dup "
+ + "BUILD DEFERRED REFRESH COMPLETE ON MANUAL\n"
+ + " DISTRIBUTED BY RANDOM BUCKETS 1\n"
+ + " PROPERTIES ('replication_num' = '1',
'binlog.enable' = 'true', "
+ + "'binlog.format' = 'ROW') \n"
+ + " as select k1, v1 from
test.row_binlog_schema_base;");
+ createMvByNereids("create materialized view row_binlog_schema_plain "
+ + "BUILD DEFERRED REFRESH COMPLETE ON MANUAL\n"
+ + " DISTRIBUTED BY RANDOM BUCKETS 1\n"
+ + " PROPERTIES ('replication_num' = '1') \n"
+ + " as select k1, v1 from
test.row_binlog_schema_base;");
+
+ Database db =
Env.getCurrentEnv().getInternalCatalog().getDbOrAnalysisException("test");
+ MTMV ivmMv = (MTMV)
db.getTableOrAnalysisException("row_binlog_schema_ivm");
+ MTMV dupMv = (MTMV)
db.getTableOrAnalysisException("row_binlog_schema_dup");
+ MTMV plainMv = (MTMV)
db.getTableOrAnalysisException("row_binlog_schema_plain");
+
+ Assertions.assertEquals(Lists.newArrayList(Column.COMMIT_TSO_COL),
rowBinlogHiddenColumns(ivmMv));
+ Assertions.assertEquals(Lists.newArrayList(Column.COMMIT_TSO_COL,
Column.ROW_LSN_COL),
+ rowBinlogHiddenColumns(dupMv));
+ Assertions.assertTrue(rowBinlogHiddenColumns(plainMv).isEmpty());
+
+ for (MTMV mtmv : Lists.newArrayList(ivmMv, dupMv, plainMv)) {
+ ConnectContext ctx = MTMVPlanUtil.createMTMVContext(mtmv,
+ MTMVPlanUtil.DISABLE_RULES_WHEN_GENERATE_MTMV_CACHE);
+ Assertions.assertDoesNotThrow(() ->
MTMVPlanUtil.ensureMTMVQueryUsable(mtmv, ctx),
+ "analyzed schema must match the physical schema of " +
mtmv.getName());
+ }
+ }
+
+ private static List<String> rowBinlogHiddenColumns(MTMV mtmv) {
+ List<String> hidden = Lists.newArrayList();
+ for (Column column : mtmv.getBaseSchema(true)) {
+ if (column.getName().equalsIgnoreCase(Column.COMMIT_TSO_COL)
+ || column.getName().equalsIgnoreCase(Column.ROW_LSN_COL)) {
+ hidden.add(column.getName().toUpperCase());
+ }
+ }
+ return hidden;
+ }
+
@Test
public void testEnsureMTMVQueryAnalyzeFailed() throws Exception {
createTable("CREATE TABLE IF NOT EXISTS analyze_faild_t_partition (\n"
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPropertyUtilTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPropertyUtilTest.java
index 4593e498e1d..2fbc5a06359 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPropertyUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVPropertyUtilTest.java
@@ -106,6 +106,19 @@ class MTMVPropertyUtilTest {
Assertions.assertTrue(MTMVPropertyUtil.getIvmPartitionWindowLimit(null).isEmpty());
}
+ @Test
+ void testHasPartitionSyncLimit() {
+ String key = PropertyAnalyzer.PROPERTIES_PARTITION_SYNC_LIMIT;
+ Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(null));
+
Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of()));
+
Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
"")));
+ // A limit that keeps nothing is not a limit: no base partition is
filtered out by it.
+
Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
"0")));
+
Assertions.assertFalse(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
"-1")));
+
Assertions.assertTrue(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
"1")));
+
Assertions.assertTrue(MTMVPropertyUtil.hasPartitionSyncLimit(ImmutableMap.of(key,
"2")));
+ }
+
@Test
void testGetPartitionWindowLimitNameMatching() {
Map<TableNameInfo, Integer> windowLimits =
diff --git a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVTaskTest.java
index 20112a936f3..123b3bcd463 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
@@ -816,12 +816,46 @@ public class MTMVTaskTest {
Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
Deencapsulation.invoke(task, "validateIvmBaselineBeforePartitionSync",
request);
- Assertions.assertTrue((Boolean) Deencapsulation.invoke(task,
"handlePendingIvmBaselineRebuild",
- Mockito.mock(MTMVRefreshContext.class), request, new
ConnectContext()));
- Assertions.assertEquals(MTMVTask.MTMVTaskRefreshMode.NOT_REFRESH,
- Deencapsulation.getField(task, "refreshMode"));
+ List<Object> attempts = Lists.newArrayList();
+ attempts.addAll(Deencapsulation.invoke(task, "buildAttempts", request,
false));
+ Assertions.assertEquals("[PARTITIONS, COMPLETE]", attempts.toString());
+
+ Deencapsulation.invoke(task, "handlePendingIvmBaselineRebuild",
+ Mockito.mock(MTMVRefreshContext.class), request, new
ConnectContext(), attempts);
+
+ // A pending COMPLETE rebuild reshapes the attempt list instead of
rebuilding inline, so
+ // PARTITIONS FALLBACK rebuilds the whole MV through the COMPLETE
attempt it keeps.
+ Assertions.assertEquals("[COMPLETE]", attempts.toString());
+ Assertions.assertEquals(IvmFailureReason.BINLOG_BROKEN.name(),
+ Deencapsulation.getField(task, "ivmFallbackReason"));
+ // The barrier is released by the caller once the reshaped attempts
have run.
+ Mockito.verify(mtmv,
Mockito.never()).releaseIvmBaselineRebuild(Mockito.anyLong());
+ }
+
+ @Test
+ public void testDroppedBaselinePartitionsReleaseBarrierWithoutRebuild()
throws Exception {
+ Mockito.when(mtmv.isIvm()).thenReturn(true);
+ IvmInfo ivmInfo = new IvmInfo();
+ ivmInfo.addPendingBaselineRebuildPartitions(Sets.newHashSet(poneName));
+ Mockito.when(mtmv.getIvmInfo()).thenReturn(ivmInfo);
+ // Partition sync already dropped the partition the barrier named, so
nothing is left to
+ // pre-rebuild and the surviving partitions catch up through the
attempts themselves.
+
Mockito.when(mtmv.getPartitionNames()).thenReturn(Sets.newHashSet(ptwoName));
+ MTMVTask task = new MTMVTask(mtmv, relation, MTMVTaskContext.of(
+ MTMVTaskTriggerMode.MANUAL, null, RefreshMode.PARTITIONS,
true, null));
+ Deencapsulation.setField(task, "mtmvSchemaChangeVersion", 7L);
+ Object request = Deencapsulation.invoke(task, "resolveRefreshRequest");
+
+ List<Object> attempts = Lists.newArrayList();
+ attempts.addAll(Deencapsulation.invoke(task, "buildAttempts", request,
false));
+ Deencapsulation.invoke(task, "handlePendingIvmBaselineRebuild",
+ Mockito.mock(MTMVRefreshContext.class), request, new
ConnectContext(), attempts);
+
+ Assertions.assertEquals("[PARTITIONS, COMPLETE]", attempts.toString());
+ Assertions.assertNull(Deencapsulation.getField(task, "refreshMode"));
Assertions.assertEquals(IvmFailureReason.BINLOG_BROKEN.name(),
Deencapsulation.getField(task, "ivmFallbackReason"));
+ Mockito.verify(mtmv).releaseIvmBaselineRebuild(7L);
}
@Test
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java
index 2588ca5f532..dfd3d6dc7ef 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java
@@ -104,6 +104,24 @@ public class IvmBaselineRebuildTest extends
TestWithFeService {
Assertions.assertTrue(getMtmv(db).getIvmInfo().isBaselineRebuildRequired());
}
+ @Test
+ public void testDropColumnMarksBaselineRebuildOnlyWhenReferenced() throws
Exception {
+ String db = "ivm_broken_drop_column";
+ createPartitionedIvmTableAndMv(db);
+ MTMV mtmv = getMtmv(db);
+
+ // ivm_mv selects dt, k1, v1. Dropping a column it does not use must
leave the baseline alone.
+ executeSql("ALTER TABLE ivm_base ADD COLUMN spare int");
+ executeSql("ALTER TABLE ivm_base DROP COLUMN spare");
+ Assertions.assertFalse(mtmv.getIvmInfo().isBaselineRebuildRequired());
+
+ // Dropping a column the MV uses makes the MV query unanalyzable: the
change is metadata-only
+ // and emits no binlog, so an incremental refresh would silently keep
the rows of the old
+ // column. The baseline has to be invalidated instead.
+ executeSql("ALTER TABLE ivm_base DROP COLUMN v1");
+ Assertions.assertTrue(mtmv.getIvmInfo().isBaselineRebuildRequired());
+ }
+
@Test
public void testPublishedPctPartitionUsesPartitionsBaselineRebuild()
throws Exception {
String db = "ivm_partitions_baseline_rebuild";
@@ -217,6 +235,24 @@ public class IvmBaselineRebuildTest extends
TestWithFeService {
Assertions.assertFalse(getMtmv(db).getIvmInfo().isBaselineRebuildRequired());
}
+ @Test
+ public void testRenameTableBackKeepsIncrementalRefreshStartable() throws
Exception {
+ String db = "ivm_broken_rename_table_back";
+ createPartitionedIvmTableAndMv(db);
+
+ executeSql("ALTER TABLE ivm_base RENAME ivm_base_renamed");
+ executeSql("ALTER TABLE ivm_base_renamed RENAME ivm_base");
+
+ // A rename changes no column, so it must not invalidate the baseline
in either direction:
+ // once the table is renamed back, the MV query is analyzable again
and a strict INCREMENTAL
+ // refresh has to be able to start. A "baseline rebuild required" flag
left behind by the
+ // rename would reject every one of them until a COMPLETE refresh had
been run, even though
+ // nothing the MV depends on ever changed.
+ MTMV mtmv = getMtmv(db);
+ Assertions.assertFalse(mtmv.getIvmInfo().isBaselineRebuildRequired());
+ Assertions.assertDoesNotThrow(() ->
mtmv.validateIvmRefreshStart(mtmv.getSchemaChangeVersion()));
+ }
+
@Test
public void testReplaceTableMarksBaselineRebuild() throws Exception {
String db = "ivm_broken_replace_table";
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriterTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriterTest.java
index ac8b48a4d44..e3f4a074efd 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriterTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaRewriterTest.java
@@ -19,7 +19,10 @@ package org.apache.doris.mtmv.ivm;
import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.MTMV;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.Partition;
import org.apache.doris.common.util.PropertyAnalyzer;
+import org.apache.doris.mtmv.BaseTableInfo;
import org.apache.doris.nereids.analyzer.UnboundTableSink;
import org.apache.doris.nereids.jobs.JobContext;
import org.apache.doris.nereids.rules.analysis.CheckAfterRewrite;
@@ -50,8 +53,12 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
+import java.util.ArrayList;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
class IvmDeltaRewriterTest extends IvmDeltaTestBase {
@@ -112,6 +119,25 @@ class IvmDeltaRewriterTest extends IvmDeltaTestBase {
new JoinReorderContext());
}
+ private Plan generateMergedDeltaWithScope(Plan plan, Map<BaseTableInfo,
Set<Long>> scopePartitionIds) {
+ ConnectContext connectContext = newConnectContext();
+ JobContext jobContext = newJobContextForRoot(plan, connectContext);
+ Plan normalizedPlan = new IvmNormalizeMTMV().rewriteRoot(plan,
jobContext);
+ IvmRewriteResult rewriteResult =
jobContext.getCascadesContext().getIvmRewriteResult().get();
+ MTMV mtmv = buildMtmvFromPlan(normalizedPlan.getOutput());
+ return new IvmDeltaRewriter().generateIncrRefreshPlan(normalizedPlan,
rewriteResult,
+ IvmRewriteContext.incremental(mtmv, scopePartitionIds),
connectContext);
+ }
+
+ /** The partition selection of every olap scan in the plan, in plan order.
*/
+ private List<List<Long>> collectedPartitionSelections(Plan plan) {
+ List<List<Long>> selections = new ArrayList<>();
+ for (LogicalOlapScan scan : collectScans(plan)) {
+ selections.add(new ArrayList<>(scan.getSelectedPartitionIds()));
+ }
+ return selections;
+ }
+
private List<LogicalOlapScan> collectScans(Plan plan) {
return plan.collectToList(n -> n instanceof LogicalOlapScan);
}
@@ -297,6 +323,53 @@ class IvmDeltaRewriterTest extends IvmDeltaTestBase {
Assertions.assertTrue(IvmDeltaRewriteHelper.INSTANCE.isIncrementalDeltaScan(collectScans(rewritten).get(0)));
}
+ @Test
+ void testIncrementalScopeRestrictsDeltaToScopePartitions() {
+ LogicalOlapScan scan = buildScanForTableWithTwoPartitions(120,
"scope_subset");
+ OlapTable table = scan.getTable();
+ bumpBaseTableTso(table, 20);
+ setStreamOffset(table, getRegisteredStream(table, 1L), 10);
+ long keptPartitionId = table.getPartition("p2").getId();
+
+ Plan rewritten = generateMergedDeltaWithScope(scan,
+ ImmutableMap.of(new BaseTableInfo(table),
Sets.newHashSet(keptPartitionId)));
+
+ List<LogicalOlapScan> scans = collectScans(rewritten);
+ Assertions.assertFalse(scans.isEmpty());
+ for (LogicalOlapScan rewrittenScan : scans) {
+ Assertions.assertEquals(ImmutableList.of(keptPartitionId),
+ rewrittenScan.getSelectedPartitionIds());
+ }
+ }
+
+ @Test
+ void testIncrementalScopeCoveringAllPartitionsLeavesScanUnchanged() {
+ LogicalOlapScan scan = buildScanForTableWithTwoPartitions(121,
"scope_all");
+ OlapTable table = scan.getTable();
+ bumpBaseTableTso(table, 20);
+ setStreamOffset(table, getRegisteredStream(table, 1L), 10);
+
+ Plan withoutScope = generateMergedDelta(scan, false);
+ Plan withScope = generateMergedDeltaWithScope(scan,
ImmutableMap.of(new BaseTableInfo(table),
+
table.getPartitions().stream().map(Partition::getId).collect(Collectors.toSet())));
+
+ Assertions.assertEquals(collectedPartitionSelections(withoutScope),
+ collectedPartitionSelections(withScope));
+ }
+
+ @Test
+ void testIncrementalScopeWithoutPartitionsProducesEmptyRelation() {
+ LogicalOlapScan scan = buildScanForTableWithTwoPartitions(122,
"scope_empty");
+ OlapTable table = scan.getTable();
+ bumpBaseTableTso(table, 20);
+ setStreamOffset(table, getRegisteredStream(table, 1L), 10);
+
+ Plan rewritten = generateMergedDeltaWithScope(scan,
+ ImmutableMap.of(new BaseTableInfo(table), Sets.newHashSet()));
+
+ Assertions.assertInstanceOf(LogicalEmptyRelation.class, rewritten);
+ }
+
@Test
void testRecursiveJoinDeltaMergesBothScanDeltas() {
LogicalOlapScan left = buildScanForTable(301, "recursive_left");
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
index 6e6e5fd80a6..32c78df1adc 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmDeltaTestBase.java
@@ -480,7 +480,11 @@ abstract class IvmDeltaTestBase {
return;
}
long partitionId = table.getId() * 100 + 1;
- Partition partition = new Partition(partitionId, "p1",
+ addTestPartition(table, "p1", partitionId);
+ }
+
+ private void addTestPartition(OlapTable table, String name, long
partitionId) {
+ Partition partition = new Partition(partitionId, name,
new MaterializedIndex(table.getBaseIndexId(),
MaterializedIndex.IndexState.NORMAL),
new RandomDistributionInfo(1));
partition.setVisibleVersionAndTime(Partition.PARTITION_INIT_VERSION +
1,
@@ -488,4 +492,19 @@ abstract class IvmDeltaTestBase {
partition.setNextVersion(Partition.PARTITION_INIT_VERSION + 2);
table.addPartition(partition);
}
+
+ /**
+ * Builds a scan for a table with two partitions, p1 and p2, so that a
partition subset is
+ * available to restrict a scan to.
+ */
+ protected LogicalOlapScan buildScanForTableWithTwoPartitions(long tableId,
String tableName) {
+ OlapTable table = PlanConstructor.newOlapTable(tableId, tableName, 0);
+ addTestPartition(table);
+ addTestPartition(table, "p2", tableId * 100 + 2);
+ enableRowBinlog(table);
+ table.setQualifiedDbName("test_db");
+ registerTestStreams(table);
+ return new LogicalOlapScan(PlanConstructor.getNextRelationId(), table,
+ ImmutableList.of("test_db"));
+ }
}
diff --git
a/regression-test/data/mtmv_p0/ivm/test_ivm_drop_referenced_column_baseline_rebuild.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_drop_referenced_column_baseline_rebuild.out
new file mode 100644
index 00000000000..8793c2b56bc
--- /dev/null
+++
b/regression-test/data/mtmv_p0/ivm/test_ivm_drop_referenced_column_baseline_rebuild.out
@@ -0,0 +1,8 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !mv_rows_baseline --
+10 2 300
+20 1 300
+
+-- !mv_rows_after_aba --
+0 3 600
+
diff --git
a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.out
new file mode 100644
index 00000000000..271a5f56d8e
--- /dev/null
+++
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.out
@@ -0,0 +1,32 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !baseline_task --
+SUCCESS NONE NONE
+
+-- !baseline_base --
+2026-01-10 1 10
+2026-01-10 1 10
+2026-02-10 3 30
+2026-02-10 3 30
+
+-- !baseline_mv --
+2026-01-10 1 10
+2026-01-10 1 10
+2026-02-10 3 30
+2026-02-10 3 30
+
+-- !strict_task --
+FAILED NOT_REFRESH BINLOG_BROKEN
+
+-- !fallback_task --
+SUCCESS PARTIAL BINLOG_BROKEN
+
+-- !fallback_base --
+2026-02-10 3 30
+2026-02-10 3 30
+2026-02-15 4 40
+
+-- !fallback_mv --
+2026-02-10 3 30
+2026-02-10 3 30
+2026-02-15 4 40
+
diff --git
a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.out
new file mode 100644
index 00000000000..fab4869556a
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.out
@@ -0,0 +1,45 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !baseline_task --
+SUCCESS NONE
+
+-- !baseline_base --
+2026-01-10 1 10
+2026-02-10 2 20
+2026-03-10 3 30
+
+-- !baseline_mv --
+2026-01-10 1 10
+2026-02-10 2 20
+2026-03-10 3 30
+
+-- !strict_task --
+FAILED BINLOG_BROKEN
+
+-- !fallback_task --
+SUCCESS BINLOG_BROKEN
+
+-- !fallback_base --
+2026-02-10 2 20
+2026-02-15 4 40
+2026-03-10 3 30
+
+-- !fallback_mv --
+2026-02-10 2 20
+2026-02-15 4 40
+2026-03-10 3 30
+
+-- !resumed_task --
+SUCCESS NONE
+
+-- !resumed_base --
+2026-02-10 2 20
+2026-02-15 4 40
+2026-03-10 3 30
+2026-03-15 5 50
+
+-- !resumed_mv --
+2026-02-10 2 20
+2026-02-15 4 40
+2026-03-10 3 30
+2026-03-15 5 50
+
diff --git a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit.out
new file mode 100644
index 00000000000..ba46dd58702
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit.out
@@ -0,0 +1,14 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !complete_task --
+SUCCESS COMPLETE NONE
+
+-- !complete_mv --
+2 20 \N
+3 30 known
+
+-- !incremental_task --
+SUCCESS NONE NONE
+
+-- !incremental_mv --
+2 20 late-arriving
+3 30 known
diff --git
a/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.out
new file mode 100644
index 00000000000..f6eb8f2e733
--- /dev/null
+++
b/regression-test/data/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.out
@@ -0,0 +1,15 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !complete_task --
+SUCCESS COMPLETE NONE
+
+-- !complete_mv --
+2 20 \N
+3 30 \N
+
+-- !incremental_task --
+SUCCESS NONE NONE
+
+-- !incremental_mv --
+2 20 \N
+3 30 late-arriving
+
diff --git a/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out
index 1208b149aa3..686e463d2f3 100644
--- a/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_refresh_dry_run.out
@@ -5,8 +5,8 @@
3 1 30
-- !ivm_dry_run_full --
-1 1 1 15 1 1 4097 0
-4 4 1 40 1 1 4097 0
+1 1 1 15 1 1 [regression-fake-sequence] 0
+4 4 1 40 1 1 [regression-fake-sequence] 0
-- !ivm_dry_run_after --
1 1 10
@@ -14,8 +14,8 @@
3 1 30
-- !ivm_dry_run_repeat --
-1 1 1 15 1 1 4097 0
-4 4 1 40 1 1 4097 0
+1 1 1 15 1 1 [regression-fake-sequence] 0
+4 4 1 40 1 1 [regression-fake-sequence] 0
-- !ivm_dry_run_after_refresh --
1 1 15
@@ -23,3 +23,7 @@
3 1 30
4 1 40
+-- !ivm_dry_run_empty --
+
+-- !ivm_dry_run_empty_repeat --
+
diff --git
a/regression-test/data/mtmv_p0/ivm/test_ivm_row_binlog_schema_validation.out
b/regression-test/data/mtmv_p0/ivm/test_ivm_row_binlog_schema_validation.out
new file mode 100644
index 00000000000..9d9acb797cb
--- /dev/null
+++ b/regression-test/data/mtmv_p0/ivm/test_ivm_row_binlog_schema_validation.out
@@ -0,0 +1,27 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !rb_ivm_rows --
+1 10
+2 20
+
+-- !rb_ivm_no_binlog_rows --
+1 10
+2 20
+
+-- !rb_dup_rows --
+1 10
+2 20
+
+-- !rb_dup_no_binlog_rows --
+1 10
+2 20
+
+-- !cascade_l2_baseline --
+10 2 300
+20 1 300
+
+-- !cascade_l1_after_aba --
+0 3 600
+
+-- !cascade_l2_after_aba --
+0 3 600
+
diff --git
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
index d906c610ed8..6ef166d9459 100644
---
a/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
+++
b/regression-test/framework/src/main/groovy/org/apache/doris/regression/suite/Suite.groovy
@@ -1727,8 +1727,10 @@ class Suite implements GroovyInterceptable {
return result
}
- // rowConverter: { row -> convertedRow }
+ // rowConverter: { row -> convertedRow }, or { row, meta -> convertedRow }
to inspect the result
+ // metadata, for example to mask a column whose value depends on the
deployment mode.
void quickRunTest(String tag, Object arg, boolean isOrder = false, Closure
rowConverter = null) {
+ boolean converterNeedsMeta = rowConverter != null &&
rowConverter.maximumNumberOfParameters > 1
if (context.config.generateOutputFile ||
context.config.forceGenerateOutputFile) {
Tuple2<List<List<Object>>, ResultSetMetaData> tupleResult = null
if (arg instanceof PreparedStatement) {
@@ -1763,7 +1765,9 @@ class Suite implements GroovyInterceptable {
}
def (result, meta) = tupleResult
if (rowConverter != null) {
- result = result.collect { rowConverter.call(it) }
+ result = result.collect {
+ converterNeedsMeta ? rowConverter.call(it, meta) :
rowConverter.call(it)
+ }
}
if (isOrder) {
result = sortByToString(result)
@@ -1815,7 +1819,9 @@ class Suite implements GroovyInterceptable {
}
def (realResults, meta) = tupleResult
if (rowConverter != null) {
- realResults = realResults.collect { rowConverter.call(it) }
+ realResults = realResults.collect {
+ converterNeedsMeta ? rowConverter.call(it, meta) :
rowConverter.call(it)
+ }
}
if (isOrder) {
realResults = sortByToString(realResults)
diff --git a/regression-test/plugins/plugin_planner.groovy
b/regression-test/plugins/plugin_planner.groovy
index 0a5a12c402a..eca9594bd2d 100644
--- a/regression-test/plugins/plugin_planner.groovy
+++ b/regression-test/plugins/plugin_planner.groovy
@@ -135,3 +135,24 @@ Suite.metaClass.explainIvmPlan = { String tag, String sql
->
)
}
+// __DORIS_SEQUENCE_COL__ of an IVM dry-run delta encodes the refresh version,
whose base value
+// differs between cloud and shared-nothing deployments. Mask that column so
that both modes can
+// share one suite and one .out file.
+def maskIvmDryRunSequence = { row, meta ->
+ for (int i = 1; i <= meta.getColumnCount(); i++) {
+ if ("__DORIS_SEQUENCE_COL__".equalsIgnoreCase(meta.getColumnLabel(i)))
{
+ def masked = new ArrayList(row)
+ masked.set(i - 1, "[regression-fake-sequence]")
+ return masked
+ }
+ }
+ return row
+}
+
+// Named ivm_dry_run_qt instead of order_qt_*: Suite.invokeMethod intercepts
every method whose
+// name starts with qt_ / order_qt_ and derives the tag from the method name,
so a metaClass
+// method could never be reached under those prefixes.
+Suite.metaClass.ivm_dry_run_qt = { String tag, String sql ->
+ delegate.quickRunTest(tag, sql, true, maskIvmDryRunSequence)
+}
+
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_bitmap_runtime_fallback.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_bitmap_runtime_fallback.groovy
index fcbe8689e88..c49540bdc44 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_bitmap_runtime_fallback.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_bitmap_runtime_fallback.groovy
@@ -18,7 +18,7 @@
import org.awaitility.Awaitility
import static java.util.concurrent.TimeUnit.SECONDS
-suite("test_ivm_bitmap_runtime_fallback", "nonConcurrent") {
+suite("test_ivm_bitmap_runtime_fallback") {
sql """drop materialized view if exists ivm_bm_fb_mv"""
sql """drop table if exists ivm_bm_fb_t"""
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_column_fallback_reason.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_column_fallback_reason.groovy
index 2019247d816..db9d5be3052 100644
---
a/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_column_fallback_reason.groovy
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_column_fallback_reason.groovy
@@ -18,11 +18,7 @@
import org.awaitility.Awaitility
import static java.util.concurrent.TimeUnit.SECONDS
-suite("test_ivm_drop_column_fallback_reason", "nonConcurrent") {
- if (isCloudMode()) {
- return
- }
-
+suite("test_ivm_drop_column_fallback_reason") {
sql """DROP MATERIALIZED VIEW IF EXISTS ivm_drop_col_reason_mv"""
sql """DROP TABLE IF EXISTS ivm_drop_col_reason_t"""
sql """
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_referenced_column_baseline_rebuild.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_referenced_column_baseline_rebuild.groovy
new file mode 100644
index 00000000000..26dd37ff2d0
--- /dev/null
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_drop_referenced_column_baseline_rebuild.groovy
@@ -0,0 +1,144 @@
+// 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
+
+// Dropping a column that an IVM references, then re-adding a column with the
same name
+// (schema ABA), used to let a strict INCREMENTAL refresh report SUCCESS while
silently
+// keeping the rows computed under the old column epoch.
+//
+// The base table change is metadata-only (light schema change) and emits no
binlog, so the
+// delta is empty and the refresh has nothing to apply -- the MV baseline is
simply stale.
+//
+// Expected: dropping a referenced column invalidates the IVM baseline, so a
strict
+// INCREMENTAL refresh is rejected and the user is told to run a COMPLETE
refresh.
+// Dropping an unreferenced column must still leave the incremental path
untouched.
+suite("test_ivm_drop_referenced_column_baseline_rebuild") {
+ def tableName = "ivm_drop_ref_col_t"
+ def mvName = "ivm_drop_ref_col_mv"
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ${tableName}"""
+
+ sql """
+ CREATE TABLE ${tableName} (
+ id BIGINT NOT NULL,
+ grp INT NULL,
+ amount BIGINT NULL,
+ spare INT NULL
+ )
+ UNIQUE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+ sql """INSERT INTO ${tableName} VALUES (1, 10, 100, 7), (2, 10, 200, 8),
(3, 20, 300, 9)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ${mvName}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ KEY(grp)
+ DISTRIBUTED BY HASH(grp) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT grp, COUNT(*) AS cnt, SUM(amount) AS total
+ FROM ${tableName} GROUP BY grp
+ """
+
+ def ddlJobCount = { String table ->
+ return sql("""SHOW ALTER TABLE COLUMN WHERE TableName =
'${table}'""").size()
+ }
+
+ // `SHOW ALTER TABLE COLUMN` keeps finished jobs, so wait for a *new* job
that is FINISHED.
+ def waitDdlFinished = { String table, int previousJobCount ->
+ Awaitility.await().atMost(300, SECONDS).pollInterval(2,
SECONDS).until({
+ def jobs = sql """SHOW ALTER TABLE COLUMN WHERE TableName =
'${table}'"""
+ return jobs.size() > previousJobCount
+ && jobs.every({ row -> row[9].toString() == 'FINISHED' })
+ })
+ }
+
+ def lastTaskId = null
+ // tasks('type'='mv') can briefly miss the just-finished task, so require
a *new* TaskId.
+ def waitTerminalTask = { String mv ->
+ def taskResult
+ Awaitility.await().atMost(300, SECONDS).pollInterval(2,
SECONDS).until({
+ taskResult = sql_return_maparray("""
+ SELECT TaskId, Status, RefreshMode, IvmFallbackReason, ErrorMsg
+ FROM tasks('type'='mv')
+ WHERE MvDatabaseName = '${context.dbName}' AND MvName = '${mv}'
+ ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+ """)
+ return !taskResult.isEmpty()
+ && taskResult[0].TaskId.toString() != lastTaskId
+ && taskResult[0].Status.toString() != 'PENDING'
+ && taskResult[0].Status.toString() != 'RUNNING'
+ })
+ lastTaskId = taskResult[0].TaskId.toString()
+ return taskResult[0]
+ }
+
+ // ---------------------------------------------------------------- 1.
baseline
+ sql """REFRESH MATERIALIZED VIEW ${mvName} COMPLETE"""
+ def task = waitTerminalTask(mvName)
+ assertEquals("SUCCESS", task.Status.toString(), "baseline COMPLETE
refresh: " + task.ErrorMsg)
+ order_qt_mv_rows_baseline "SELECT grp, cnt, total FROM ${mvName}"
+
+ // ------------------------------------- 2. unreferenced column: no
baseline invalidation
+ def before = ddlJobCount(tableName)
+ sql """ALTER TABLE ${tableName} DROP COLUMN spare"""
+ waitDdlFinished(tableName, before)
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+ task = waitTerminalTask(mvName)
+ assertEquals("SUCCESS", task.Status.toString(),
+ "dropping an unreferenced column must not invalidate the IVM
baseline: " + task.ErrorMsg)
+
+ // ---------------------------------------- 3. referenced column: strict
INCREMENTAL rejected
+ before = ddlJobCount(tableName)
+ sql """ALTER TABLE ${tableName} DROP COLUMN grp"""
+ waitDdlFinished(tableName, before)
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+ task = waitTerminalTask(mvName)
+ assertEquals("FAILED", task.Status.toString(),
+ "dropping a referenced column must reject a strict INCREMENTAL
refresh")
+
+ // -------------------------------- 4. same-name re-add (schema ABA) is
still rejected
+ before = ddlJobCount(tableName)
+ sql """ALTER TABLE ${tableName} ADD COLUMN grp INT NULL DEFAULT '0'"""
+ waitDdlFinished(tableName, before)
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+ task = waitTerminalTask(mvName)
+ assertEquals("FAILED", task.Status.toString(),
+ "schema ABA must not be silently accepted by a strict INCREMENTAL
refresh")
+ assertTrue(task.ErrorMsg.toString().contains("baseline rebuild is
pending"),
+ "expected a pending baseline rebuild hint, got: " + task.ErrorMsg)
+
+ // ------------------------------------ 5. COMPLETE rebuild reflects
current base semantics
+ // Every pre-existing row now reads the re-added column's default value.
+ sql """REFRESH MATERIALIZED VIEW ${mvName} COMPLETE"""
+ task = waitTerminalTask(mvName)
+ assertEquals("SUCCESS", task.Status.toString(), "COMPLETE rebuild after
ABA: " + task.ErrorMsg)
+ order_qt_mv_rows_after_aba "SELECT grp, cnt, total FROM ${mvName}"
+}
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_fallback_stream_multi_batch.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_fallback_stream_multi_batch.groovy
index 3d15539f795..dab63c2e2b3 100644
---
a/regression-test/suites/mtmv_p0/ivm/test_ivm_fallback_stream_multi_batch.groovy
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_fallback_stream_multi_batch.groovy
@@ -16,10 +16,6 @@
// under the License.
suite("test_ivm_fallback_stream_multi_batch", "nonConcurrent") {
- if (isCloudMode()) {
- return
- }
-
def forcedFallbackDebugPoint =
"IvmIncrRefreshManager.doRefresh.force_fallback_reason"
GetDebugPoint().disableDebugPointForAllFEs(forcedFallbackDebugPoint)
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_fallback_stream_multi_batch_dup.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_fallback_stream_multi_batch_dup.groovy
index 7e6480c5ef5..18bbd652e57 100644
---
a/regression-test/suites/mtmv_p0/ivm/test_ivm_fallback_stream_multi_batch_dup.groovy
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_fallback_stream_multi_batch_dup.groovy
@@ -16,10 +16,6 @@
// under the License.
suite("test_ivm_fallback_stream_multi_batch_dup", "nonConcurrent") {
- if (isCloudMode()) {
- return
- }
-
def forcedFallbackDebugPoint =
"IvmIncrRefreshManager.doRefresh.force_fallback_reason"
GetDebugPoint().disableDebugPointForAllFEs(forcedFallbackDebugPoint)
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_minmax_runtime_fallback.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_minmax_runtime_fallback.groovy
index 1ee2153a53e..4bc1f069d78 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_minmax_runtime_fallback.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_minmax_runtime_fallback.groovy
@@ -18,7 +18,7 @@
import org.awaitility.Awaitility
import static java.util.concurrent.TimeUnit.SECONDS
-suite("test_ivm_minmax_runtime_fallback", "nonConcurrent") {
+suite("test_ivm_minmax_runtime_fallback") {
sql """drop materialized view if exists ivm_mm_fb_mv"""
sql """drop table if exists ivm_mm_fb_t"""
diff --git a/regression-test/suites/mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy
index 5e272898f06..7c51417417c 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_mtmv_row_binlog.groovy
@@ -15,11 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-suite("test_ivm_mtmv_row_binlog", "nonConcurrent") {
- if (isCloudMode()) {
- return
- }
-
+suite("test_ivm_mtmv_row_binlog") {
sql """DROP MATERIALIZED VIEW IF EXISTS test_ivm_mtmv_row_binlog_mv;"""
sql """DROP TABLE IF EXISTS test_ivm_mtmv_row_binlog_base;"""
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild.groovy
index 150856e3f16..488e7bd44c3 100644
---
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild.groovy
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild.groovy
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-suite("test_ivm_partition_baseline_rebuild", "nonConcurrent") {
+suite("test_ivm_partition_baseline_rebuild") {
def tableName = "ivm_part_rebuild_t"
def mvName = "ivm_part_rebuild_mv"
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.groovy
new file mode 100644
index 00000000000..8f849196d47
--- /dev/null
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_baseline_rebuild_dup_keys.groovy
@@ -0,0 +1,123 @@
+// 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
+
+/**
+ * Same baseline-rebuild pre-step as test_ivm_partition_drop_live_delta, but
on the branch where the
+ * affected MV partition SURVIVES: TRUNCATE keeps the partition range, so
partition sync leaves the
+ * MV partition in place and the pre-step really has something to rebuild.
+ *
+ * <p>Two things are pinned here. The refreshed partition is picked up again
by the IVM attempt that
+ * follows, which may only apply the remaining delta -- on a duplicate-key MV
a double apply shows up
+ * as extra copies of the same row, not as a wrong value. And the row written
to the surviving
+ * partition after the truncate must still be consumed. Both are checked by
comparing whole result
+ * sets, so row multiplicities are part of the expectation.
+ */
+suite("test_ivm_partition_baseline_rebuild_dup_keys") {
+ def tableName = "ivm_part_dup_t"
+ def mvName = "ivm_part_dup_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 RefreshMode / IvmFallbackReason come back as the literal
two-character string "\N",
+ // which does not survive the .out round trip, so fold the unset value
into a printable token.
+ def taskQuery = { String taskId ->
+ """
+ SELECT Status,
+ CASE WHEN RefreshMode IN ('COMPLETE', 'PARTIAL',
'NOT_REFRESH')
+ THEN RefreshMode ELSE 'NONE' END,
+ CASE WHEN IvmFallbackReason = 'BINLOG_BROKEN'
+ THEN IvmFallbackReason ELSE 'NONE' END
+ FROM tasks('type'='mv')
+ WHERE TaskId = '${taskId}'
+ """
+ }
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ${tableName}"""
+ sql """
+ CREATE TABLE ${tableName} (
+ dt DATE NOT NULL,
+ id INT NOT NULL,
+ v INT
+ )
+ DUPLICATE KEY(dt, id)
+ PARTITION BY RANGE(dt) ()
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW"
+ )
+ """
+ sql """ALTER TABLE ${tableName} ADD PARTITION p202601 VALUES
[('2026-01-01'), ('2026-02-01'))"""
+ sql """ALTER TABLE ${tableName} ADD PARTITION p202602 VALUES
[('2026-02-01'), ('2026-03-01'))"""
+ // Repeated identical rows: a double-applied delta grows the multiplicity
instead of hiding in a
+ // unique key.
+ sql """INSERT INTO ${tableName} VALUES
+ ('2026-01-10', 1, 10), ('2026-01-10', 1, 10),
+ ('2026-02-10', 3, 30), ('2026-02-10', 3, 30)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ${mvName}
+ BUILD DEFERRED REFRESH INCREMENTAL FALLBACK ON MANUAL
+ PARTITION BY(dt)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT dt, id, v FROM ${tableName}
+ """
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+ def taskId = waitForNewTask(null)
+ qt_baseline_task taskQuery(taskId)
+ order_qt_baseline_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt,
id, v"""
+ order_qt_baseline_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id,
v"""
+
+ // TRUNCATE replaces the partition, so the MV partition of that range
stays alive and the
+ // baseline pre-step has a real partition to rebuild.
+ sql """TRUNCATE TABLE ${tableName} PARTITION(p202601)"""
+ sql """INSERT INTO ${tableName} VALUES ('2026-02-15', 4, 40)"""
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+ taskId = waitForNewTask(taskId)
+ qt_strict_task taskQuery(taskId)
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL FALLBACK"""
+ taskId = waitForNewTask(taskId)
+ qt_fallback_task taskQuery(taskId)
+ order_qt_fallback_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt,
id, v"""
+ order_qt_fallback_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id,
v"""
+}
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.groovy
new file mode 100644
index 00000000000..bc9f182e306
--- /dev/null
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_drop_live_delta.groovy
@@ -0,0 +1,134 @@
+// 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
+
+/**
+ * Dropping a base-table partition invalidates the IVM baseline, because the
rows disappear through
+ * metadata rather than through row binlog entries. The MV partition built
from that base partition
+ * is then removed by partition sync, which is exactly what the baseline
barrier recorded.
+ *
+ * <p>The refresh must still consume the delta that accumulated on the
*surviving* partitions: it
+ * may not report SUCCESS while leaving those partitions stale. This case
inserts a row into a
+ * surviving partition after the drop, so an EMPTY baseline-rebuild
intersection cannot be mistaken
+ * for "nothing to do".
+ *
+ * <p>Partitions are managed by hand (no dynamic partition scheduler) and
every dt is a literal, so
+ * the case is fully deterministic.
+ */
+suite("test_ivm_partition_drop_live_delta") {
+ def tableName = "ivm_part_drop_t"
+ def mvName = "ivm_part_drop_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()
+ }
+
+ // An unset IvmFallbackReason 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.
+ def taskQuery = { String taskId ->
+ """
+ SELECT Status,
+ CASE WHEN IvmFallbackReason = 'BINLOG_BROKEN' THEN
IvmFallbackReason ELSE 'NONE' END
+ FROM tasks('type'='mv')
+ WHERE TaskId = '${taskId}'
+ """
+ }
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ${tableName}"""
+ sql """
+ CREATE TABLE ${tableName} (
+ dt DATE NOT NULL,
+ id INT NOT NULL,
+ v INT
+ )
+ UNIQUE KEY(dt, id)
+ PARTITION BY RANGE(dt) ()
+ DISTRIBUTED BY HASH(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 ${tableName} ADD PARTITION p202601 VALUES
[('2026-01-01'), ('2026-02-01'))"""
+ sql """ALTER TABLE ${tableName} ADD PARTITION p202602 VALUES
[('2026-02-01'), ('2026-03-01'))"""
+ sql """ALTER TABLE ${tableName} ADD PARTITION p202603 VALUES
[('2026-03-01'), ('2026-04-01'))"""
+ sql """ALTER TABLE ${tableName} ADD PARTITION p202604 VALUES
[('2026-04-01'), ('2026-05-01'))"""
+ sql """INSERT INTO ${tableName} VALUES
+ ('2026-01-10', 1, 10), ('2026-02-10', 2, 20), ('2026-03-10', 3,
30)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ${mvName}
+ BUILD DEFERRED REFRESH INCREMENTAL FALLBACK ON MANUAL
+ KEY(dt, id)
+ PARTITION BY(dt)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT dt, id, v FROM ${tableName}
+ """
+
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+ def taskId = waitForNewTask(null)
+ qt_baseline_task taskQuery(taskId)
+ order_qt_baseline_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt,
id"""
+ order_qt_baseline_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id"""
+
+ sql """ALTER TABLE ${tableName} DROP PARTITION p202601"""
+ sql """INSERT INTO ${tableName} VALUES ('2026-02-15', 4, 40)"""
+
+ // A strict incremental refresh must refuse to run against a broken
baseline.
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+ taskId = waitForNewTask(taskId)
+ qt_strict_task taskQuery(taskId)
+
+ // The fallback reports SUCCESS, so the MV has to match the base table
afterwards: the expired
+ // partition is gone AND the row written to the surviving partition has
been consumed. An MV
+ // that is missing that row means the refresh silently skipped the
surviving partitions' delta.
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL FALLBACK"""
+ taskId = waitForNewTask(taskId)
+ qt_fallback_task taskQuery(taskId)
+ order_qt_fallback_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt,
id"""
+ order_qt_fallback_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id"""
+
+ // A following strict incremental refresh must be able to continue from
the repaired baseline.
+ sql """INSERT INTO ${tableName} VALUES ('2026-03-15', 5, 50)"""
+ sql """REFRESH MATERIALIZED VIEW ${mvName} INCREMENTAL"""
+ taskId = waitForNewTask(taskId)
+ qt_resumed_task taskQuery(taskId)
+ order_qt_resumed_base """SELECT dt, id, v FROM ${tableName} ORDER BY dt,
id"""
+ order_qt_resumed_mv """SELECT dt, id, v FROM ${mvName} ORDER BY dt, id"""
+}
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit.groovy
new file mode 100644
index 00000000000..24009d05987
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit.groovy
@@ -0,0 +1,159 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/**
+ * An MV that keeps only part of the base table's partitions must not break
the incremental
+ * refresh when a dimension change touches base rows that live outside that
window.
+ *
+ * <p>The MV below is partitioned by the fact table's dt and keeps only
partitions whose range
+ * upper bound is greater than the start of the current year
(partition_sync_limit=1 with
+ * partition_sync_time_unit=YEAR), so p_dropped is filtered out of the MV
while p_kept stays.
+ * Its partition set is therefore a strict subset of the base table's.
+ *
+ * <p>A late-arriving dimension row for key 99 produces a delta that joins the
dimension events
+ * against the fact snapshot; the snapshot covers every fact partition, so it
also matches the
+ * fact row stored in p_dropped and emits a delta row for a date the MV has no
partition for.
+ * The insert then fails with "no partition for this tuple", and because the
write is atomic the
+ * in-window repair is lost as well. The refresh must instead ignore the parts
of the delta that
+ * fall outside the MV's partition set and still repair the partition it does
keep.
+ *
+ * <p>All dates are literals and every partition is created by hand: no
current_date() and no
+ * dynamic partition scheduler, so the expectation does not depend on the run
date. Only the
+ * YEAR unit is used to keep the kept/dropped split stable for any run date in
this century.
+ */
+suite("test_ivm_partition_sync_limit") {
+ def factTable = "ivm_pwld_f"
+ def dimTable = "ivm_pwld_d"
+ def mvName = "ivm_pwld_mv"
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ${factTable}"""
+ sql """DROP TABLE IF EXISTS ${dimTable}"""
+
+ sql """
+ CREATE TABLE ${factTable} (
+ order_id BIGINT NOT NULL,
+ dt DATE NOT NULL,
+ dimension_id INT,
+ amount INT
+ )
+ UNIQUE KEY(order_id, dt)
+ PARTITION BY RANGE(dt) ()
+ DISTRIBUTED BY HASH(order_id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+ // Empty PARTITION BY range plus manual ADD PARTITION: the partition
layout does not depend
+ // on the date the suite runs.
+ sql """ALTER TABLE ${factTable} ADD PARTITION p_dropped VALUES
[('2019-01-01'), ('2020-01-01'))"""
+ sql """ALTER TABLE ${factTable} ADD PARTITION p_kept VALUES
[('2026-01-01'), ('2099-01-01'))"""
+
+ sql """
+ CREATE TABLE ${dimTable} (
+ dimension_id INT NOT NULL,
+ dimension_name VARCHAR(32)
+ )
+ UNIQUE KEY(dimension_id)
+ DISTRIBUTED BY HASH(dimension_id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+
+ sql """INSERT INTO ${dimTable} VALUES (10, 'known')"""
+ // order_id 1 and 2 share dimension key 99, which has no dimension row
yet: order_id 1 sits in
+ // the partition the MV does not keep, order_id 2 in the one it does.
+ sql """INSERT INTO ${factTable} VALUES
+ (1, '2019-06-01', 99, 10),
+ (2, '2026-06-15', 99, 20),
+ (3, '2026-06-16', 10, 30)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ${mvName}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ KEY(order_id, dt)
+ PARTITION BY(dt)
+ DISTRIBUTED BY HASH(order_id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "partition_sync_limit" = "1",
+ "partition_sync_time_unit" = "YEAR"
+ )
+ AS SELECT f.order_id, f.dt, f.amount, d.dimension_name
+ FROM ${factTable} f
+ LEFT JOIN ${dimTable} d ON f.dimension_id = d.dimension_id
+ """
+
+ // Waiting through the framework helper rather than by reading the newest
row: tasks() can
+ // briefly miss a task that just finished, and this suite reuses the MV
name across runs, so
+ // the newest row can be another run's task. Only the id is taken here;
the row the .out
+ // compares comes from taskQuery below.
+ def refreshAndGetTaskId = { String mode ->
+ sql """REFRESH MATERIALIZED VIEW ${mvName} ${mode}"""
+ waitingMTMVTaskFinishedByMvName(mvName)
+ def rows = sql_return_maparray("""
+ SELECT TaskId FROM tasks('type'='mv')
+ WHERE MvDatabaseName = '${context.dbName}' AND MvName = '${mvName}'
+ ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+ """)
+ assert !rows.isEmpty(): "no refresh task for ${mode} on ${mvName}"
+ return rows[0].TaskId.toString()
+ }
+
+ // Unset RefreshMode / IvmFallbackReason come back as the literal
two-character string "\N",
+ // which does not survive the .out round trip, so fold the unset value
into a printable token.
+ def taskQuery = { String taskId ->
+ """
+ SELECT Status,
+ CASE WHEN RefreshMode IN ('COMPLETE', 'PARTIAL',
'NOT_REFRESH')
+ THEN RefreshMode ELSE 'NONE' END,
+ CASE WHEN IvmFallbackReason = 'BINLOG_BROKEN'
+ THEN IvmFallbackReason ELSE 'NONE' END
+ FROM tasks('type'='mv')
+ WHERE TaskId = '${taskId}'
+ """
+ }
+
+ // The MV keeps p_kept only, so order_id 1 is never part of it.
+ def taskId = refreshAndGetTaskId("COMPLETE")
+ qt_complete_task taskQuery(taskId)
+ order_qt_complete_mv """
+ SELECT order_id, amount, dimension_name FROM ${mvName} ORDER BY
order_id
+ """
+
+ // A late-arriving dimension row for key 99. Its delta must repair
order_id 2 (in p_kept) and
+ // must not try to write order_id 1 into a partition the MV does not have.
+ sql """INSERT INTO ${dimTable} VALUES (99, 'late-arriving')"""
+ taskId = refreshAndGetTaskId("INCREMENTAL")
+ qt_incremental_task taskQuery(taskId)
+ order_qt_incremental_mv """
+ SELECT order_id, amount, dimension_name FROM ${mvName} ORDER BY
order_id
+ """
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ${factTable}"""
+ sql """DROP TABLE IF EXISTS ${dimTable}"""
+}
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.groovy
new file mode 100644
index 00000000000..2713db9333f
--- /dev/null
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_partition_sync_limit_with_window.groovy
@@ -0,0 +1,157 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+/**
+ * partition_sync_limit and ivm_partition_window_limit together: the base
partitions the
+ * incremental delta may read are the INTERSECTION of the two.
+ *
+ * <p>The MV keeps the partitions above the start of the current year
(partition_sync_limit=1 with
+ * YEAR), which is p_mid and p_new, while p_dropped is filtered out. The
compute window keeps the
+ * last partition by value, which is p_new alone.
+ *
+ * <p>So only p_new may be read: p_dropped must stay unread, or the delta
would emit rows for a
+ * date the MV has no partition for, and p_mid must stay unread too, or the
window would be
+ * ignored and the MV would be maintained outside it. A dimension change
touches one fact row in
+ * each partition, so the expectation separates the three: p_new is repaired,
p_mid keeps the
+ * value the window says not to maintain, p_dropped is not part of the MV at
all.
+ *
+ * <p>All dates are literals and every partition is created by hand: no
current_date() and no
+ * dynamic partition scheduler. The YEAR unit and the far-future range upper
bounds keep the
+ * kept/dropped split stable for any run date in this century.
+ */
+suite("test_ivm_partition_sync_limit_with_window") {
+ def factTable = "ivm_pslw_f"
+ def dimTable = "ivm_pslw_d"
+ def mvName = "ivm_pslw_mv"
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ${factTable}"""
+ sql """DROP TABLE IF EXISTS ${dimTable}"""
+
+ sql """
+ CREATE TABLE ${factTable} (
+ order_id BIGINT NOT NULL,
+ dt DATE NOT NULL,
+ dimension_id INT,
+ amount INT
+ )
+ UNIQUE KEY(order_id, dt)
+ PARTITION BY RANGE(dt) ()
+ DISTRIBUTED BY HASH(order_id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+ sql """ALTER TABLE ${factTable} ADD PARTITION p_dropped VALUES
[('2019-01-01'), ('2020-01-01'))"""
+ sql """ALTER TABLE ${factTable} ADD PARTITION p_mid VALUES
[('2026-01-01'), ('2099-01-01'))"""
+ sql """ALTER TABLE ${factTable} ADD PARTITION p_new VALUES
[('2099-01-01'), ('2199-01-01'))"""
+
+ sql """
+ CREATE TABLE ${dimTable} (
+ dimension_id INT NOT NULL,
+ dimension_name VARCHAR(32)
+ )
+ UNIQUE KEY(dimension_id)
+ DISTRIBUTED BY HASH(dimension_id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "enable_unique_key_merge_on_write" = "true",
+ "binlog.enable" = "true",
+ "binlog.format" = "ROW",
+ "binlog.need_historical_value" = "true"
+ )
+ """
+
+ sql """INSERT INTO ${dimTable} VALUES (10, 'known')"""
+ // One fact row per partition, all on dimension key 99, which has no
dimension row yet.
+ sql """INSERT INTO ${factTable} VALUES
+ (1, '2019-06-01', 99, 10),
+ (2, '2026-06-15', 99, 20),
+ (3, '2099-06-15', 99, 30)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ${mvName}
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL
+ KEY(order_id, dt)
+ PARTITION BY(dt)
+ DISTRIBUTED BY HASH(order_id) BUCKETS 1
+ PROPERTIES (
+ "replication_num" = "1",
+ "partition_sync_limit" = "1",
+ "partition_sync_time_unit" = "YEAR",
+ "ivm_partition_window_limit" = "${factTable}:1"
+ )
+ AS SELECT f.order_id, f.dt, f.amount, d.dimension_name
+ FROM ${factTable} f
+ LEFT JOIN ${dimTable} d ON f.dimension_id = d.dimension_id
+ """
+
+ // Waiting through the framework helper rather than by reading the newest
row: tasks() can
+ // briefly miss a task that just finished, and this suite reuses the MV
name across runs, so
+ // the newest row can be another run's task. Only the id is taken here;
the row the .out
+ // compares comes from taskQuery below.
+ def refreshAndGetTaskId = { String mode ->
+ sql """REFRESH MATERIALIZED VIEW ${mvName} ${mode}"""
+ waitingMTMVTaskFinishedByMvName(mvName)
+ def rows = sql_return_maparray("""
+ SELECT TaskId FROM tasks('type'='mv')
+ WHERE MvDatabaseName = '${context.dbName}' AND MvName = '${mvName}'
+ ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+ """)
+ assert !rows.isEmpty(): "no refresh task for ${mode} on ${mvName}"
+ return rows[0].TaskId.toString()
+ }
+
+ // Unset RefreshMode / IvmFallbackReason come back as the literal
two-character string "\N",
+ // which does not survive the .out round trip, so fold the unset value
into a printable token.
+ def taskQuery = { String taskId ->
+ """
+ SELECT Status,
+ CASE WHEN RefreshMode IN ('COMPLETE', 'PARTIAL',
'NOT_REFRESH')
+ THEN RefreshMode ELSE 'NONE' END,
+ CASE WHEN IvmFallbackReason = 'BINLOG_BROKEN'
+ THEN IvmFallbackReason ELSE 'NONE' END
+ FROM tasks('type'='mv')
+ WHERE TaskId = '${taskId}'
+ """
+ }
+
+ // The MV keeps p_mid and p_new, so order_id 1 is never part of it. The
window is an
+ // incremental-path property, so the complete refresh still builds both MV
partitions.
+ sql """REFRESH MATERIALIZED VIEW ${mvName} COMPLETE"""
+ def taskId = refreshAndGetTaskId("COMPLETE")
+ qt_complete_task taskQuery(taskId)
+ order_qt_complete_mv """
+ SELECT order_id, amount, dimension_name FROM ${mvName} ORDER BY
order_id
+ """
+
+ // A late-arriving dimension row for key 99 touches the fact row in every
partition.
+ sql """INSERT INTO ${dimTable} VALUES (99, 'late-arriving')"""
+ taskId = refreshAndGetTaskId("INCREMENTAL")
+ qt_incremental_task taskQuery(taskId)
+ order_qt_incremental_mv """
+ SELECT order_id, amount, dimension_name FROM ${mvName} ORDER BY
order_id
+ """
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mvName}"""
+ sql """DROP TABLE IF EXISTS ${factTable}"""
+ sql """DROP TABLE IF EXISTS ${dimTable}"""
+}
diff --git a/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy
index 5cb89692a6f..f2583e14e8d 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_refresh_dry_run.groovy
@@ -16,14 +16,9 @@
// under the License.
suite("test_ivm_refresh_dry_run") {
- // Cloud mode: __DORIS_SEQUENCE_COL__ in the dry-run delta rows derives
from cloud txn
- // versioning and differs from local (e.g. 6145 vs 4097), so the .out
values
- // for the sequence column do not apply.
- if (isCloudMode()) {
- logger.info("skip test_ivm_refresh_dry_run on cloud mode: " +
- "__DORIS_SEQUENCE_COL__ differs between cloud and local")
- return
- }
+ // __DORIS_SEQUENCE_COL__ of the dry-run delta rows encodes the refresh
version, whose base
+ // value differs between cloud and shared-nothing deployments (e.g. 6145
vs 4097).
+ // ivm_dry_run_qt masks that column, so cloud and local share this suite
and its .out.
sql "DROP MATERIALIZED VIEW IF EXISTS test_ivm_refresh_dry_run_mv"
sql "DROP TABLE IF EXISTS test_ivm_refresh_dry_run_base"
@@ -65,9 +60,9 @@ suite("test_ivm_refresh_dry_run") {
order_qt_ivm_dry_run_before "SELECT k1, cnt, sum_v1 FROM
test_ivm_refresh_dry_run_mv"
- order_qt_ivm_dry_run_full """
+ ivm_dry_run_qt("ivm_dry_run_full", """
REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL WITH
DRY RUN
- """
+ """)
// The delta rows picked by a LIMIT depend on scan order (no ORDER BY
before the cap),
// so assert only the returned row count instead of exact rows in the .out
file.
@@ -77,11 +72,21 @@ suite("test_ivm_refresh_dry_run") {
order_qt_ivm_dry_run_after "SELECT k1, cnt, sum_v1 FROM
test_ivm_refresh_dry_run_mv"
- order_qt_ivm_dry_run_repeat """
+ ivm_dry_run_qt("ivm_dry_run_repeat", """
REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL WITH
DRY RUN LIMIT 10
- """
+ """)
sql "REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL"
waitingMTMVTaskFinishedByMvName("test_ivm_refresh_dry_run_mv")
order_qt_ivm_dry_run_after_refresh "SELECT k1, cnt, sum_v1 FROM
test_ivm_refresh_dry_run_mv"
+
+ // Empty delta: the delta query is a LogicalEmptyRelation and the dry run
must return an
+ // empty result instead of sending fragments to a placeholder backend.
Every dry-run
+ // comparison goes through ivm_dry_run_qt so the sequence column is masked
in all of them.
+ ivm_dry_run_qt("ivm_dry_run_empty", """
+ REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL WITH
DRY RUN
+ """)
+ ivm_dry_run_qt("ivm_dry_run_empty_repeat", """
+ REFRESH MATERIALIZED VIEW test_ivm_refresh_dry_run_mv INCREMENTAL WITH
DRY RUN
+ """)
}
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_rewrite_projection.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_rewrite_projection.groovy
index cf8d21b7831..fd9f41e168d 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_rewrite_projection.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_rewrite_projection.groovy
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-suite("test_ivm_rewrite_projection", "nonConcurrent") {
+suite("test_ivm_rewrite_projection") {
sql """drop materialized view if exists rewrite_projection_ivm;"""
sql """drop table if exists rewrite_projection_base;"""
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_row_binlog_schema_validation.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_row_binlog_schema_validation.groovy
new file mode 100644
index 00000000000..b93dff85fdd
--- /dev/null
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_row_binlog_schema_validation.groovy
@@ -0,0 +1,194 @@
+// 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
+
+// A materialized view re-validates its schema against a fresh analysis of its
own query whenever a
+// base table changes (MTMVPlanUtil.ensureMTMVQueryUsable ->
checkColumnIfChange). A table created
+// with row binlog carries hidden columns that the query never produces, and
an MV carries them too
+// when its own properties enable row binlog (which cascade IVM requires). The
analyzed schema has to
+// contain them as well, otherwise every refresh of such an MV fails with
+// "column length not equals, please check whether columns of base table have
changed" -- including a
+// COMPLETE refresh, which is the only way out of a stale baseline.
+//
+// Covered here: the four MV shapes, and the DORIS-28306 shape where a cascade
L1 could not recover
+// through COMPLETE after a schema ABA on a referenced column.
+suite("test_ivm_row_binlog_schema_validation") {
+ def rowBinlogProps = "'replication_num' = '1', 'binlog.enable' = 'true',
'binlog.format' = 'ROW'"
+ def plainProps = "'replication_num' = '1'"
+ // A cascade source keeps historical values so its own downstream MV can
read the binlog.
+ def cascadeProps = rowBinlogProps + ", 'binlog.need_historical_value' =
'true'"
+
+ def ddlJobCount = { String table ->
+ return sql("""SHOW ALTER TABLE COLUMN WHERE TableName =
'${table}'""").size()
+ }
+
+ // `SHOW ALTER TABLE COLUMN` keeps finished jobs, so wait for a *new* job
that is FINISHED.
+ def waitDdlFinished = { String table, int previousJobCount ->
+ Awaitility.await().atMost(300, SECONDS).pollInterval(2,
SECONDS).until({
+ def jobs = sql """SHOW ALTER TABLE COLUMN WHERE TableName =
'${table}'"""
+ return jobs.size() > previousJobCount
+ && jobs.every({ row -> row[9].toString() == 'FINISHED' })
+ })
+ }
+
+ def lastTaskId = null
+ // tasks('type'='mv') can briefly miss the just-finished task, so require
a *new* TaskId.
+ def waitTerminalTask = { String mv ->
+ def taskResult
+ Awaitility.await().atMost(300, SECONDS).pollInterval(2,
SECONDS).until({
+ taskResult = sql_return_maparray("""
+ SELECT TaskId, Status, RefreshMode, ErrorMsg
+ FROM tasks('type'='mv')
+ WHERE MvDatabaseName = '${context.dbName}' AND MvName = '${mv}'
+ ORDER BY CreateTime DESC, TaskId DESC LIMIT 1
+ """)
+ return !taskResult.isEmpty()
+ && taskResult[0].TaskId.toString() != lastTaskId
+ && taskResult[0].Status.toString() != 'PENDING'
+ && taskResult[0].Status.toString() != 'RUNNING'
+ })
+ lastTaskId = taskResult[0].TaskId.toString()
+ return taskResult[0]
+ }
+
+ def refresh = { String mv, String mode ->
+ sql """REFRESH MATERIALIZED VIEW ${mv} ${mode}"""
+ return waitTerminalTask(mv)
+ }
+
+ // ---------------------------------------------------------------- 1.
four MV shapes
+ // Same query and same base table for all four; what differs is whether
the MV itself enables
+ // row binlog, and (for the IVM ones) whether the MV is a MOW unique table.
+ def cases = [
+ [name: "rb_ivm", mvProps: rowBinlogProps, ivm: true],
+ [name: "rb_ivm_no_binlog", mvProps: plainProps, ivm: true],
+ [name: "rb_dup", mvProps: rowBinlogProps, ivm: false],
+ [name: "rb_dup_no_binlog", mvProps: plainProps, ivm: false],
+ ]
+
+ for (def c : cases) {
+ def table = c.name + "_base"
+ def mv = c.name + "_mv"
+
+ sql """DROP MATERIALIZED VIEW IF EXISTS ${mv}"""
+ sql """DROP TABLE IF EXISTS ${table}"""
+ sql """
+ CREATE TABLE ${table} (
+ k1 INT NOT NULL,
+ v1 INT NULL,
+ spare INT NULL
+ )
+ 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 ${table} VALUES (1, 10, 1), (2, 20, 1)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW ${mv}
+ BUILD DEFERRED REFRESH ${c.ivm ? 'INCREMENTAL' : 'COMPLETE'} ON
MANUAL
+ KEY(k1)
+ DISTRIBUTED BY HASH(k1) BUCKETS 1
+ PROPERTIES (${c.mvProps})
+ AS SELECT k1, SUM(v1) AS total FROM ${table} GROUP BY k1
+ """
+ def task = refresh(mv, "COMPLETE")
+ assertEquals("SUCCESS", task.Status.toString(), "${c.name} baseline
refresh: " + task.ErrorMsg)
+
+ // A schema change that leaves the query intact: the MV must stay
refreshable. The dropped
+ // column is not referenced, so nothing about the MV's result changes.
+ def before = ddlJobCount(table)
+ sql """ALTER TABLE ${table} DROP COLUMN spare"""
+ waitDdlFinished(table, before)
+
+ task = refresh(mv, c.ivm ? "INCREMENTAL" : "COMPLETE")
+ assertEquals("SUCCESS", task.Status.toString(),
+ "${c.name} refresh after an unrelated DROP COLUMN: " +
task.ErrorMsg)
+ }
+
+ // The dropped column was never referenced, so all four MVs must still
hold their baseline rows.
+ order_qt_rb_ivm_rows "SELECT k1, total FROM rb_ivm_mv"
+ order_qt_rb_ivm_no_binlog_rows "SELECT k1, total FROM rb_ivm_no_binlog_mv"
+ order_qt_rb_dup_rows "SELECT k1, total FROM rb_dup_mv"
+ order_qt_rb_dup_no_binlog_rows "SELECT k1, total FROM rb_dup_no_binlog_mv"
+
+ // ------------------------------------- 2. DORIS-28306: cascade L1 must
recover via COMPLETE
+ // A cascade forces row binlog onto L1, so L1 is exactly the shape that
used to make every
+ // refresh of L1 fail after a schema change. An ABA on a referenced column
must still leave the
+ // explicit COMPLETE recovery path usable.
+ def cTable = "cascade_aba_base"
+ sql """DROP MATERIALIZED VIEW IF EXISTS cascade_l2"""
+ sql """DROP MATERIALIZED VIEW IF EXISTS cascade_l1"""
+ sql """DROP TABLE IF EXISTS ${cTable}"""
+ sql """
+ CREATE TABLE ${cTable} (
+ id BIGINT NOT NULL,
+ grp INT NULL,
+ amount BIGINT NULL
+ )
+ UNIQUE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ('replication_num' = '1',
'enable_unique_key_merge_on_write' = 'true',
+ 'binlog.enable' = 'true', 'binlog.format' = 'ROW',
+ 'binlog.need_historical_value' = 'true')
+ """
+ sql """INSERT INTO ${cTable} VALUES (1, 10, 100), (2, 10, 200), (3, 20,
300)"""
+
+ sql """
+ CREATE MATERIALIZED VIEW cascade_l1
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL KEY(id)
+ PROPERTIES (${cascadeProps})
+ AS SELECT id, grp, amount FROM ${cTable}
+ """
+ def cTask = refresh("cascade_l1", "COMPLETE")
+ assertEquals("SUCCESS", cTask.Status.toString(), "cascade L1 baseline: " +
cTask.ErrorMsg)
+
+ sql """
+ CREATE MATERIALIZED VIEW cascade_l2
+ BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL KEY(grp)
+ PROPERTIES (${cascadeProps})
+ AS SELECT grp, COUNT(*) AS row_count, SUM(amount) AS total_amount FROM
cascade_l1 GROUP BY grp
+ """
+ cTask = refresh("cascade_l2", "COMPLETE")
+ assertEquals("SUCCESS", cTask.Status.toString(), "cascade L2 baseline: " +
cTask.ErrorMsg)
+ order_qt_cascade_l2_baseline "SELECT grp, row_count, total_amount FROM
cascade_l2"
+
+ def cBefore = ddlJobCount(cTable)
+ sql """ALTER TABLE ${cTable} DROP COLUMN grp"""
+ waitDdlFinished(cTable, cBefore)
+ cBefore = ddlJobCount(cTable)
+ sql """ALTER TABLE ${cTable} ADD COLUMN grp INT NULL DEFAULT '0'"""
+ waitDdlFinished(cTable, cBefore)
+
+ // COMPLETE is the recovery path: it has to rebuild L1 from the current
base-table semantics,
+ // where every pre-existing row now reads the re-added column's default
value. Whether the
+ // strict INCREMENTAL in between is rejected is a separate contract, owned
by the suite for the
+ // baseline invalidation itself, so it is deliberately not asserted here
-- this suite has to
+ // hold with or without that change.
+ cTask = refresh("cascade_l1", "COMPLETE")
+ assertEquals("SUCCESS", cTask.Status.toString(), "COMPLETE after schema
ABA: " + cTask.ErrorMsg)
+ order_qt_cascade_l1_after_aba "SELECT grp, COUNT(*) AS c, SUM(amount) AS s
FROM cascade_l1 GROUP BY grp"
+
+ // ... and the downstream level stays refreshable afterwards.
+ cTask = refresh("cascade_l2", "COMPLETE")
+ assertEquals("SUCCESS", cTask.Status.toString(), "cascade L2 after L1
recovery: " + cTask.ErrorMsg)
+ order_qt_cascade_l2_after_aba "SELECT grp, row_count, total_amount FROM
cascade_l2"
+}
diff --git
a/regression-test/suites/mtmv_p0/ivm/test_ivm_strict_failure_partition_atomicity.groovy
b/regression-test/suites/mtmv_p0/ivm/test_ivm_strict_failure_partition_atomicity.groovy
index 1b7acd59eca..13b9edc7e5c 100644
---
a/regression-test/suites/mtmv_p0/ivm/test_ivm_strict_failure_partition_atomicity.groovy
+++
b/regression-test/suites/mtmv_p0/ivm/test_ivm_strict_failure_partition_atomicity.groovy
@@ -18,7 +18,7 @@
import org.awaitility.Awaitility
import static java.util.concurrent.TimeUnit.SECONDS
-suite("test_ivm_strict_failure_partition_atomicity", "nonConcurrent") {
+suite("test_ivm_strict_failure_partition_atomicity") {
sql """DROP MATERIALIZED VIEW IF EXISTS ivm_strict_atomicity_mv"""
sql """DROP TABLE IF EXISTS ivm_strict_atomicity_t"""
sql """
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]