This is an automated email from the ASF dual-hosted git repository.
yujun777 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 3050a9ae8ae [fix](ivm) Invalidate the baseline when a column used by
the MV is dropped (#67837)
3050a9ae8ae is described below
commit 3050a9ae8ae53cf14ecda7a785ffd1a3a28f4d0c
Author: yujun <[email protected]>
AuthorDate: Fri Sep 11 22:32:46 2026 +0800
[fix](ivm) Invalidate the baseline when a column used by the MV is dropped
(#67837)
A light schema change on an IVM base table (`DROP COLUMN`, usually
followed by an `ADD COLUMN` with the same name) only changes metadata
and emits no row binlog. When the dropped column is one the MV uses, the
next `REFRESH ... INCREMENTAL` consumes an empty delta and reports
**SUCCESS**, while the MV silently keeps the rows that were computed
under the old column.
Dropping the column did reach the MTMV hook, but that only moved the MV
status to `SCHEMA_CHANGE`. That state merely re-analyses the MV query on
the next refresh - and by then the column is usually back under the same
name, so the analysis succeeds and the incremental refresh proceeds. The
IVM baseline barrier (`requireCompleteBaselineRebuild`) was never raised
on this path: it is only set for `REPLACE TABLE`, `REPLACE PARTITION`
and partition changes.
### What this PR does
Re-analyse the MV query on the `alterTable` path, right after the alter
was applied, and invalidate the IVM baseline when the query no longer
binds.
This needs no lineage machinery: dropping or renaming a column the MV
uses makes the query unanalysable, so the failure itself is the
dependency signal - and dropping a column the MV does not reference
leaves the incremental path untouched.
Scope: on an IVM base table only `DROP COLUMN` and `RENAME TABLE` can
change a referenced column at all (row binlog tables reject `MODIFY
COLUMN`, `RENAME COLUMN` and `REORDER COLUMNS`), so those two operations
are the whole surface. A dropped base table needs no handling - the IVM
stream records the base table id and stops being usable, which already
fails the refresh.
The analysis runs in a context of its own rather than the session that
issued the alter, because the underlying `analyzeQueryWithSql` reuses
and closes the statement context it is handed.
---
.../org/apache/doris/mtmv/MTMVRelationManager.java | 61 ++++++++-
.../doris/alter/SchemaChangeHandlerTest.java | 8 ++
.../doris/mtmv/ivm/IvmBaselineRebuildTest.java | 36 ++++++
...ivm_drop_referenced_column_baseline_rebuild.out | 8 ++
..._drop_referenced_column_baseline_rebuild.groovy | 144 +++++++++++++++++++++
5 files changed, 253 insertions(+), 4 deletions(-)
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 19266feb1f9..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
@@ -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/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/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/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/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}"
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]