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 dd456ab7a89 [improvement](mtmv) Judge a base table column change by
the MV query it reaches (#68646)
dd456ab7a89 is described below
commit dd456ab7a894f54c6553415223c6d5c9c7d4e035
Author: yujun <[email protected]>
AuthorDate: Sat Oct 10 18:41:32 2026 +0800
[improvement](mtmv) Judge a base table column change by the MV query it
reaches (#68646)
A column change on a base table (`DROP COLUMN` / `RENAME COLUMN`) puts
every materialized view that reads the table into `SCHEMA_CHANGE`,
whether or not the column is one the view's query names. That state is
what a strict `REFRESH ... INCREMENTAL` on an IVM MV reads -- the
refresh is escalated to a whole-MV `COMPLETE` for a column the MV never
read -- and it is what stops the transparent rewrite serving a plain MV
until a refresh has run. Neither is work the change owes.
The judgement such a change needs is the view's own query, per view: a
column that is dropped or renamed either is named by that query -- and
then the query no longer analyses, which is the state the view has to be
left in -- or it is not named, and the rows the view holds are the ones
they should be. `MTMVRelationManager` already re-analyses the query for
such a change, but only to pick a more specific reason for a record it
writes either way. This PR makes that answer the criterion, for both
kinds of MV:
* Each operation says whether the query decides it
(`AlterOp#needQueryUsabilityCheck`). `DROP COLUMN` and `RENAME COLUMN`
do. A type change does not: the query keeps analysing while the meaning
of the rows computed under the old type changes, so re-analysis says
nothing for a column the query only filters or joins on. A rename of the
table itself is not asked about either, for the reason it never was: the
view's query spells the old name, so it is unanalysable by construction,
and the query check could only attribute the invalidation to the wrong
reason. `REPLACE TABLE`, a dropped table and a base view change were
never asked about, and they still invalidate every view that reads them.
* A change that has not reached the table is not asked about
(`AlterOp#hasReachedTheTable`). A schema change that is not a light one
is applied by a job, and the MV hook runs where that job may not have
run yet: the table still holds the column the change takes away, every
query still analyses against it, and an invalidation decided on that
answer would be about the table from before the change -- while the ABA
that a dropped and re-added column leaves behind is exactly what the
invalidation exists for. What is asked is whether the change has reached
the table, which is the same fact the re-analysis reads, so the two
answers cannot disagree; a change that has not been seen that way keeps
invalidating, as it did before the queries were asked at all.
Alongside it, two things the same code owed:
* `MTMV.processBaseViewChange` applied an invalidation in a second copy
of what `MTMV.alterStatus` does (state, version bump, snapshot drop). It
goes through `alterStatus` now, so an invalidation is one place to read
whatever carried it. The view path keeps applying without a record of
its own, and that is now written down where it happens: the change to
the view is what is journaled and a replay runs this hook along with it,
so a record here would apply the same invalidation twice on a follower
-- including a version bump the leader moved once.
* `MTMVState.SCHEMA_CHANGE` now says what it means, which is more than
its name: the state is "this MV needs a whole rebuild", and the comment
names the changes that set it -- for a plain MV as well as an IVM one,
and for an IVM MV alone -- and why the constant is not renamed (its name
is what is written to disk, and a constant Gson does not know reads back
as null, dropping the state of every MV loaded from such an image
instead of failing).
An entire-table `TRUNCATE TABLE` is deliberately left as it is. The
design asked for it to be recorded as a whole-MV change; the
per-partition requirement already expresses that scope (every MV
partition that reads the table is named), and the whole-MV record would
send every partition of the MV to a rebuild and reset the streams for a
change the per-partition requirement carries -- where a stream does need
that reconcile, the refresh falls back to a `COMPLETE` on its own. Both
truncate scopes are pinned by tests now.
Behaviour changed: Yes
* A base table `DROP COLUMN` / `RENAME COLUMN` no longer invalidates a
materialized view whose query does not name the column. The view keeps
its rows and stays a rewrite candidate, and a strict `REFRESH ...
INCREMENTAL` on an IVM MV is no longer escalated to a whole-MV
`COMPLETE` because of it. A column the query does name invalidates the
view as before, and so does every other base table change.
### What each column operation does, before and after
Each row names the view it is about and the column the change is about:
| Operation on the base table | The view, and the column the change is
about | Before | After |
| --- | --- | --- | --- |
| `DROP COLUMN` | `mv: SELECT k1, SUM(amount) AS total FROM t GROUP BY
k1`; the column dropped is `spare`, which this view's query does not
read | every view that reads `t` goes to `SCHEMA_CHANGE` | the change
leaves this view alone: it keeps its rows and its rewrite snapshot, and
a strict `REFRESH ... INCREMENTAL` on an IVM view is not escalated to a
whole-view `COMPLETE`. (A column the query does read -- `amount` here --
invalidates it as before) |
| `RENAME COLUMN` | the same view; the column renamed is `spare` to
`spare2` | same as the drop: every reader is invalidated | the same
judgement as the drop. Both names are held against the query, because
neither says on its own whether the query's name moved |
| `ADD COLUMN` | `mv: SELECT o.id FROM outer_t o WHERE EXISTS (SELECT 1
FROM t i WHERE i.id = o.id AND flag = 1)` where the unqualified `flag`
is bound to `outer_t`; the column added is `t.flag` | **nothing at
all**: neither `AddColumnOp` nor `AddColumnsOp` reached the MTMV hook
(`needChangeMTMVState()` was false), so the view stayed `NORMAL` holding
rows computed with `outer_t.flag` while the query had moved to `t.flag`
| invalidated: the query reaches the name the new column answers for. (A
column this query cannot reach -- `t.unrelated` -- leaves the view
alone) |
| `MODIFY COLUMN` | `mv: SELECT k1, SUM(amount) AS total FROM t GROUP BY
k1`; the column whose type changes is `amount`, which this view only
aggregates | every reader is invalidated | unchanged: every reader is
invalidated and no query is asked. The query keeps analysing while the
rows already computed under the old type change meaning |
| an ALTER mixing clauses | the same view; `spare` dropped and the type
of `amount` changed in one statement | every reader is invalidated |
unchanged: every reader is invalidated, because the query decides a
change only where every clause of the ALTER names the column it is about
|
| `REPLACE TABLE`, `DROP TABLE`, a base view change | the same view; no
column is involved -- the whole of `t` (or a view `t` reads through) is
replaced or dropped | every reader is invalidated | unchanged |
The judgement needs the change to have reached the table for its answer
to be about the table the change left. Where it has not -- a schema
change that is not a light one, whose job has not run when the hook does
-- every reader is invalidated as before and no query is asked.
### Deferred to follow-ups
Three review findings are left open on purpose, each with the
measurement that says why, and each needs something this PR does not
carry:
- **A light schema change becomes visible to a planner before the views
that read it are invalidated**
([thread](https://github.com/apache/doris/pull/68646#discussion_r4143552253),
issue #68767). The interval is between the base table's write lock being
released (where `SchemaChangeHandler.process` has already published the
column) and the MTMV hook running from `Alter`. That is a
locking/atomicity question rather than part of the query judgement:
closing it means deciding under that lock with the journal waits
deferred, or a barrier the rewrite respects -- and observers carry the
same interval through the journal order, which the smallest of those
options would not close.
- **A view definition stored before this PR keeps its `QUALIFY`
references unqualified**
([thread](https://github.com/apache/doris/pull/68646#discussion_r4214302765)).
The visitor added here fixes the definitions a view is created with; one
already in a catalog was written by the old visitor and stays exposed to
the same move, which needs re-qualifying stored definitions (a
replay/persistence design) or holding the change against `QUALIFY`
references inside the check.
- **A name that moved into an alias over another table's column is not
judged**
([thread](https://github.com/apache/doris/pull/68646#discussion_r4219437313),
issue #68841). This and the IN-subquery alias thread beside it (fixed
here, `alias_in_subquery_*`) are the same shape to the check:
`Alias.toSlot()` gives both a slot named `flag` that comes from the
column `x`, and they differ only in where the alias was defined.
Measured: widening the branch so this one is caught flips the other one
back to `SCHEMA_CHANGE`. Both are closed by comparing the binding the
view was built with against the one the change left, which changes what
the check decides on rather than adding another name it reads.
The over-invalidation findings still open in their threads (an unused
CTE producer read as a dependency, a predicate an enclosing `EXISTS`
ignores, `ORDER BY` alias precedence, an explicitly qualified reference
to another table's column) are boundaries of a name-based reading: they
cost a rebuild and a dropped snapshot rather than a wrong answer, and
they come from the same place as the third item above. Both halves of
that reading -- what it misses and what it over-reaches -- are collected
in issue #68841 for the change that judges on the binding instead.
---
.../main/java/org/apache/doris/alter/Alter.java | 42 +-
.../main/java/org/apache/doris/catalog/MTMV.java | 29 +-
.../apache/doris/mtmv/MTMVAnalyzeQueryInfo.java | 12 +-
.../org/apache/doris/mtmv/MTMVHookService.java | 35 +-
.../java/org/apache/doris/mtmv/MTMVJobManager.java | 3 +-
.../java/org/apache/doris/mtmv/MTMVPlanUtil.java | 11 +-
.../org/apache/doris/mtmv/MTMVRefreshEnum.java | 9 +
.../org/apache/doris/mtmv/MTMVRelationManager.java | 318 +++++++++-
.../java/org/apache/doris/mtmv/MTMVService.java | 10 +-
.../trees/plans/commands/info/AddColumnOp.java | 25 +-
.../trees/plans/commands/info/AddColumnsOp.java | 28 +-
.../plans/commands/info/AlterMTMVRenameInfo.java | 2 +-
.../plans/commands/info/AlterMTMVReplaceInfo.java | 2 +-
.../nereids/trees/plans/commands/info/AlterOp.java | 42 ++
.../trees/plans/commands/info/BaseViewInfo.java | 9 +
.../trees/plans/commands/info/DropColumnOp.java | 14 +
.../trees/plans/commands/info/RenameColumnOp.java | 18 +
.../apache/doris/mtmv/MTMVRelationManagerTest.java | 31 +-
.../doris/mtmv/ivm/IvmBaselineRebuildTest.java | 295 ++++++++-
.../test_base_drop_col_multi_level_mtmv.out | 2 +-
regression-test/data/mtmv_p0/test_base_mtmv.out | 4 +-
.../test_base_rename_col_multi_level_mtmv.out | 2 +-
.../mtmv_p0/test_drop_unreferenced_column_mtmv.out | 125 ++++
..._drop_referenced_column_baseline_rebuild.groovy | 22 +-
.../test_base_drop_col_multi_level_mtmv.groovy | 4 +-
.../test_base_rename_col_multi_level_mtmv.groovy | 4 +-
.../test_drop_unreferenced_column_mtmv.groovy | 659 +++++++++++++++++++++
27 files changed, 1668 insertions(+), 89 deletions(-)
diff --git a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
index 7f482cb36ac..02afd90fc85 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/alter/Alter.java
@@ -51,6 +51,7 @@ import
org.apache.doris.common.util.PropertyAnalyzer.RewriteProperty;
import org.apache.doris.datasource.ExternalTable;
import org.apache.doris.info.TableNameInfoUtils;
import org.apache.doris.mtmv.BaseTableInfo;
+import org.apache.doris.mtmv.MTMVHookService;
import org.apache.doris.mtmv.MTMVPartitionUtil;
import org.apache.doris.mtmv.MTMVPropertyUtil;
import org.apache.doris.mtmv.MTMVRelation;
@@ -373,8 +374,29 @@ public class Alter {
throw new DdlException("Invalid alter operations: " +
currentAlterOps);
}
if (needChangeMTMVState(alterOps)) {
- Env.getCurrentEnv().getMtmvService()
- .alterTable(oldBaseTableInfo, newBaseTableInfo,
currentAlterOps.hasReplaceTableOp());
+ // Which columns an operation's effect on a view turns on is the
operation's to say, see
+ // AlterOp#queryJudgedColumnNames, and every clause of the alter
has to name them: a batch that
+ // mixes a dropped column with a type change is decided by neither
-- no query says anything
+ // about a type change -- and stays invalidated the way it was
before the queries were asked at
+ // all. Each of them also has to have reached the table. A schema
change that is not a light one
+ // is applied by a job, which may not have run where this hook
runs: the table still holds the
+ // column the change is about, every query still analyses against
it, and an invalidation
+ // decided on that answer would be about the table from before the
change. What is asked is
+ // whether the change has reached the table, which is the same
fact the re-analysis reads, so
+ // the two answers cannot disagree.
+ boolean judgedByQuery = haveReachedTheTable(alterOps, olapTable);
+ // The names of those columns go to the hook rather than a
verdict: what the judgement is about
+ // is the column, and the hook holds the query's answer against it
-- both while asking, in case
+ // the query can reach the name some other way now, and once it
has answered, in case the table
+ // is no longer the one that answered. See MTMVRelationManager.
+ MTMVHookService.QueryJudgedChange queryJudgedChange = judgedByQuery
+ ? new MTMVHookService.QueryJudgedChange(
+
alterOps.stream().map(AlterOp::queryJudgedColumnNames).flatMap(Set::stream)
+ .collect(Collectors.toSet()),
+ () -> haveReachedTheTable(alterOps, olapTable))
+ : null;
+ Env.getCurrentEnv().getMtmvService().alterTable(oldBaseTableInfo,
newBaseTableInfo,
+ currentAlterOps.hasReplaceTableOp(), queryJudgedChange);
}
olapTable.writeLock();
try {
@@ -404,6 +426,22 @@ public class Alter {
Env.getCurrentEnv().getEditLog().logModifyTableProperties(info);
}
+ /**
+ * Whether every clause of the alter names the columns it is judged on,
and every one of them has reached
+ * the table. The columns are read under the table's read lock: a light
change of another statement
+ * rebuilds the schema map, and a reader without the lock can take the map
mid rebuild, when the column
+ * this clause added is not in it yet.
+ */
+ private boolean haveReachedTheTable(List<AlterOp> alterOps, OlapTable
olapTable) {
+ olapTable.readLock();
+ try {
+ return alterOps.stream().allMatch(op ->
!op.queryJudgedColumnNames().isEmpty()
+ && op.hasReachedTheTable(olapTable));
+ } finally {
+ olapTable.readUnlock();
+ }
+ }
+
private void processAlterTableForExternalTable(
ExternalTable table, List<AlterOp> alterOps) throws UserException {
for (AlterOp alterOp : alterOps) {
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 bf58705f356..4a855624e46 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
@@ -237,7 +237,13 @@ public class MTMV extends OlapTable {
* lock and moves all three, but nothing here is journaled. It is what
{@code Alter#processAlterMTMV}
* calls for an {@code ALTER_STATUS} op, both live and on replay; a live
caller reaches it through
* {@link #invalidateWholeMv}, which goes the journaled way round. A new
invalidation belongs there:
- * calling this directly would leave the MV in a state a restart forgets.
+ * calling this directly would leave the MV in a state a restart forgets.
The one caller that goes
+ * direct is {@link #processBaseViewChange}, and it is not new: the alter
or the drop of the view is
+ * journaled, and a replay runs that hook along with it.
+ *
+ * <p>An image that is loaded and turns out to be unusable sets the state
on its own, see
+ * {@link #compatible} and {@link #gsonPostProcess}: there is no change
behind it to record, and nothing
+ * a replay could apply it against.
*/
public MTMVStatus alterStatus(MTMVStatus newStatus) {
writeMvLock();
@@ -251,16 +257,19 @@ public class MTMV extends OlapTable {
}
}
+ /**
+ * Invalidates the whole MV for a change to one of the base views it reads.
+ *
+ * <p>Applied through {@link #alterStatus}, so that the three things an
invalidation moves are moved in
+ * one place whatever carried it: the state the refresh reads, the version
that discards a task result
+ * computed against it, and the snapshot drop that stops the transparent
rewrite serving rows from it.
+ *
+ * <p>It records nothing, and that is not an omission: what is journaled
is the change to the view, and
+ * a replay re-runs this hook along with it. A record here would apply the
invalidation a second time on
+ * a follower -- including the version bump, which the leader moved once.
+ */
public void processBaseViewChange(String schemaChangeDetail) {
- writeMvLock();
- try {
- this.schemaChangeVersion++;
- this.status.setState(MTMVState.SCHEMA_CHANGE);
- this.status.setSchemaChangeDetail(schemaChangeDetail);
- this.refreshSnapshot = new MTMVRefreshSnapshot();
- } finally {
- writeMvUnlock();
- }
+ alterStatus(new MTMVStatus(MTMVState.SCHEMA_CHANGE,
schemaChangeDetail));
}
public boolean isIvm() {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVAnalyzeQueryInfo.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVAnalyzeQueryInfo.java
index ab31a7d3567..a137f43b225 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVAnalyzeQueryInfo.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVAnalyzeQueryInfo.java
@@ -32,14 +32,20 @@ public class MTMVAnalyzeQueryInfo {
private Map<String, String> properties;
// set when IVM rewrite is enabled; carries normalizedPlan + aggMeta
private IvmRewriteResult ivmRewriteResult;
+ // The plan the query was analysed into. Kept for the callers that have to
ask what the query reads
+ // rather than only that it reads something; set by
MTMVPlanUtil#analyzeQueryWithSql.
+ private Plan analyzedPlan;
public MTMVAnalyzeQueryInfo(List<ColumnDefinition> columnDefinitions,
List<String> keys,
- MTMVPartitionInfo mvPartitionInfo, MTMVRelation relation,
Map<String, String> properties) {
+ MTMVPartitionInfo mvPartitionInfo, MTMVRelation relation,
Map<String, String> properties,
+ IvmRewriteResult ivmRewriteResult, Plan analyzedPlan) {
this.columnDefinitions = columnDefinitions;
this.keys = keys;
this.mvPartitionInfo = mvPartitionInfo;
this.relation = relation;
this.properties = properties;
+ this.ivmRewriteResult = ivmRewriteResult;
+ this.analyzedPlan = analyzedPlan;
}
public List<ColumnDefinition> getColumnDefinitions() {
@@ -66,8 +72,8 @@ public class MTMVAnalyzeQueryInfo {
return properties;
}
- public void setIvmRewriteResult(IvmRewriteResult ivmRewriteResult) {
- this.ivmRewriteResult = ivmRewriteResult;
+ public Plan getAnalyzedPlan() {
+ return analyzedPlan;
}
/** Convenience accessor — returns the normalized plan, or null if IVM is
not active. */
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVHookService.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVHookService.java
index 1885d10ae45..ccd1b1e6a28 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVHookService.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVHookService.java
@@ -29,6 +29,8 @@ import
org.apache.doris.nereids.trees.plans.commands.info.RefreshMTMVInfo;
import org.apache.doris.nereids.trees.plans.commands.info.ResumeMTMVInfo;
import java.util.Optional;
+import java.util.Set;
+import java.util.function.BooleanSupplier;
/**
* Contains all operations that affect the mtmv
@@ -88,8 +90,39 @@ public interface MTMVHookService {
* @param oldTableInfo info before alter
* @param newTableInfo info after alter
* @param isReplace
+ * @param queryJudgedChange the change, left to the judgement of the query
of each MV that reads the
+ * table, or null when the alter is not one a
query decides
*/
- void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace);
+ void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace,
+ QueryJudgedChange queryJudgedChange);
+
+ /**
+ * A change to a base table that is left to the query of each view that
reads it.
+ *
+ * <p>It carries the two things such an answer is held against. The
columns are what the query is asked
+ * about: they are the names the change gives the table or takes away from
it, and a name is what the
+ * change moves -- a column added where the query reaches a name answers
for it from then on, and one
+ * taken away leaves the name to whatever else answers to it. Whether the
change has reached the table is
+ * asked again after the query has been analysed, because a table moves on
between the two, and the
+ * answer has to be about the table the change left rather than about a
later one.
+ */
+ class QueryJudgedChange {
+ private final Set<String> columns;
+ private final BooleanSupplier hasReachedTheTable;
+
+ public QueryJudgedChange(Set<String> columns, BooleanSupplier
hasReachedTheTable) {
+ this.columns = columns;
+ this.hasReachedTheTable = hasReachedTheTable;
+ }
+
+ public Set<String> columns() {
+ return columns;
+ }
+
+ public boolean hasReachedTheTable() {
+ return hasReachedTheTable.getAsBoolean();
+ }
+ }
/**
* Triggered when pause mtmv
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVJobManager.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVJobManager.java
index a1cd8db3817..9a23ca479a9 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVJobManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVJobManager.java
@@ -190,7 +190,8 @@ public class MTMVJobManager implements MTMVHookService {
}
@Override
- public void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace) {
+ public void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace,
+ QueryJudgedChange queryJudgedChange) {
}
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 aec2406a0b8..88d4e32380f 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
@@ -647,12 +647,8 @@ public class MTMVPlanUtil {
Set<String> keysSet =
Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
keysSet.addAll(keys);
validateColumns(columns, keysSet, finalEnableMergeOnWrite);
- MTMVAnalyzeQueryInfo queryInfo = new MTMVAnalyzeQueryInfo(columns,
keys, mvPartitionInfo, relation,
- properties);
- if (isIvm) {
- ivmRewriteResult.ifPresent(queryInfo::setIvmRewriteResult);
- }
- return queryInfo;
+ return new MTMVAnalyzeQueryInfo(columns, keys, mvPartitionInfo,
relation, properties,
+ isIvm ? ivmRewriteResult.orElse(null) : null,
planner.getAnalyzedPlan());
}
}
@@ -1023,7 +1019,7 @@ public class MTMVPlanUtil {
}
}
- public static void ensureMTMVQueryUsable(MTMV mtmv, ConnectContext ctx)
throws JobException {
+ public static MTMVAnalyzeQueryInfo ensureMTMVQueryUsable(MTMV mtmv,
ConnectContext ctx) throws JobException {
MTMVAnalyzeQueryInfo mtmvAnalyzedQueryInfo;
try {
mtmvAnalyzedQueryInfo = MTMVPlanUtil.analyzeQueryWithSql(mtmv, ctx,
@@ -1033,6 +1029,7 @@ public class MTMVPlanUtil {
}
checkColumnIfChange(mtmv,
mtmvAnalyzedQueryInfo.getColumnDefinitions());
checkMTMVPartitionInfo(mtmv,
mtmvAnalyzedQueryInfo.getMvPartitionInfo());
+ return mtmvAnalyzedQueryInfo;
}
private static void checkMTMVPartitionInfo(MTMV mtmv, MTMVPartitionInfo
analyzedMvPartitionInfo)
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRefreshEnum.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRefreshEnum.java
index 32c369e436f..08880ee29b4 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRefreshEnum.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRefreshEnum.java
@@ -55,6 +55,15 @@ public class MTMVRefreshEnum {
public enum MTMVState {
INIT,
NORMAL,
+ // The state means "this MV needs a whole rebuild", which its name no
longer says on its own: it is
+ // not only a base table schema change that sets it. A dropped base
table, a replace and a change to
+ // a base view set it for a plain MV as well as an IVM one; an
entire-table truncate, and a property
+ // change that brings base table partitions back into the set an MV
maintains, do so for an IVM MV.
+ //
+ // The constant name is what is written to disk, so it is not renamed:
MTMV.status is serialized
+ // with Gson without an enum adapter, which writes the constant name
and reads an unknown one back
+ // as null -- silently dropping the state of every MV loaded from such
an image. The status an
+ // ALTER_STATUS record carries is serialized the same way.
SCHEMA_CHANGE
}
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 51360b36790..fd19d0f2c7b 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
@@ -17,6 +17,7 @@
package org.apache.doris.mtmv;
+import org.apache.doris.catalog.Column;
import org.apache.doris.catalog.MTMV;
import org.apache.doris.catalog.Partition;
import org.apache.doris.catalog.Table;
@@ -27,17 +28,28 @@ import org.apache.doris.common.MetaNotFoundException;
import org.apache.doris.job.common.TaskStatus;
import org.apache.doris.job.exception.JobException;
import org.apache.doris.job.extensions.mtmv.MTMVTask;
+import org.apache.doris.nereids.lineage.LineageInfo;
+import org.apache.doris.nereids.lineage.LineageInfoExtractor;
import org.apache.doris.nereids.rules.exploration.mv.PartitionCompensator;
+import org.apache.doris.nereids.trees.expressions.Expression;
+import org.apache.doris.nereids.trees.expressions.Slot;
+import org.apache.doris.nereids.trees.expressions.SlotReference;
+import org.apache.doris.nereids.trees.plans.Plan;
import org.apache.doris.nereids.trees.plans.commands.info.CancelMTMVTaskInfo;
import org.apache.doris.nereids.trees.plans.commands.info.PauseMTMVInfo;
import org.apache.doris.nereids.trees.plans.commands.info.RefreshMTMVInfo;
import org.apache.doris.nereids.trees.plans.commands.info.ResumeMTMVInfo;
+import org.apache.doris.nereids.trees.plans.logical.LogicalApply;
+import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation;
import org.apache.doris.qe.ConnectContext;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
+import com.google.common.collect.SetMultimap;
import com.google.common.collect.Sets;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.logging.log4j.LogManager;
@@ -349,34 +361,237 @@ public class MTMVRelationManager implements
MTMVHookService {
// because a dropped table is the one change whose query is gone
beyond doubt. What the two record
// is the same state either way. Unlike a rename it stays an
invalidation: the table is gone for
// good, so the state is not something a later alter can make obsolete.
- processBaseTableChange(new BaseTableInfo(table), "The base table has
been deleted:", false);
+ processBaseTableChange(new BaseTableInfo(table), "The base table has
been deleted:", null);
}
/**
* update mtmv status to `SCHEMA_CHANGE`.
*
* @param isReplace
+ * @param queryJudgedColumns the names the alter gives the table or takes
away from it, which leave the
+ * judgement about each MV's state to that MV's
own query, or null when the
+ * alter is not one a query decides. The names
are carried rather than judged
+ * before the call because the judgement is
about them; see
+ * {@code AlterOp#queryJudgedColumnNames} for
which operations name one, and
+ * {@link #invalidateMvUnlessQueryHolds} for
what is asked about it. A rename
+ * of the base table names no column: it is left
to the record below, which
+ * says what the MV that keeps spelling the old
name needs to hear
*/
@Override
- public void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace) {
+ public void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace,
+ QueryJudgedChange queryJudgedChange) {
// when replace, need deal two table
if (isReplace) {
// REPLACE TABLE already invalidates the IVM baseline explicitly,
see Alter#processReplaceTable
- processBaseTableChange(newTableInfo.get(), "The base table has
been updated:", false);
+ processBaseTableChange(newTableInfo.get(), "The base table has
been updated:", null);
}
- boolean renamed = !isReplace && newTableInfo.isPresent()
- && !Objects.equals(oldTableInfo.getTableName(),
newTableInfo.get().getTableName());
- // A rename is the one change whose query check is skipped: the MV
query keeps spelling the old
- // name, so it is unanalyzable by construction, and the reason it
would be invalidated with --
- // "the query is no longer analyzable" -- says less than the message
this call records anyway.
- boolean checkQueryUsable = !renamed;
- processBaseTableChange(oldTableInfo, "The base table has been
updated:", checkQueryUsable);
+ processBaseTableChange(oldTableInfo, "The base table has been
updated:", queryJudgedChange);
}
/**
- * An MV's query is only as good as the base table schema it was analyzed
against. Re-analyzing the
- * MV query here (right after the alter was applied) is what detects a
changed column identity:
+ * Whether the query, as it is analysed now, reads a column of any of
these names, and reads it where
+ * the change can reach it.
+ *
+ * <p>There are two places a name is the change's to answer for. One is a
column of the table the change
+ * is about: that is the column this view's rows were computed from, and
the names are matched
+ * case-insensitively because a name is what moves. The other is a column
the query reaches across a
+ * scope boundary -- the plan records those on the Apply that stands for
the subquery, whose correlation
+ * slots are the outer columns its right side reads -- because such a name
is the scopes' to answer for
+ * rather than the query's: the nearest column to the reference answers
for it, so a column the change
+ * takes away from a scope inside leaves the name to one outside, and a
column it gives to a scope inside
+ * takes the name over. A name reached with the qualifier of another table
inside the query's own scope
+ * is neither: no later change can move it, so one to a column it does not
name is one this view's rows
+ * do not depend on.
+ */
+ private static boolean reachesAnyColumnOf(Plan plan, BaseTableInfo
baseTableInfo, Set<String> columnNames) {
+ if (plan == null) {
+ // A query whose plan was not kept is one this cannot be answered
about, and "it does" is the
+ // answer that keeps the view safe.
+ return true;
+ }
+ Set<String> names = Sets.newTreeSet(String.CASE_INSENSITIVE_ORDER);
+ names.addAll(columnNames);
+ LineageInfo lineage = LineageInfoExtractor.extractLineageInfo(plan);
+ for (SetMultimap<?, Expression> byType :
lineage.getDirectLineageMap().values()) {
+ if (reachesAnyColumn(byType.values(), names, baseTableInfo)) {
+ return true;
+ }
+ }
+ // The dataset predicates once, not once per output column: the
per-output copy of them the lineage
+ // also offers holds the same expressions for every column the query
produces, and scanning it would
+ // visit each of them once per column.
+ if (reachesAnyColumn(lineage.getDatasetIndirectLineageMap().values(),
names, baseTableInfo)) {
+ return true;
+ }
+ if (reachesAnyColumnOfASubquery(plan, names, baseTableInfo)) {
+ return true;
+ }
+ return reachesAnyColumnAcrossScopes(plan, lineage, names,
baseTableInfo);
+ }
+
+ /** Whether this slot is a column of this table, through whatever views
stand between the two. */
+ private static boolean isColumnOf(Slot slot, BaseTableInfo baseTableInfo) {
+ if (!(slot instanceof SlotReference)) {
+ return false;
+ }
+ return ((SlotReference) slot).getOriginalTable()
+ .map(table -> new BaseTableInfo(table).equals(baseTableInfo))
+ .orElse(false);
+ }
+
+ /**
+ * Whether a name the change is about is answered for inside a subquery,
out of that subquery's own
+ * scope.
+ *
+ * <p>This is the one place a name can move without any column the view
produces depending on it: the
+ * scope of a subquery is internal, so which column answers for a name
there changes what the query
+ * returns -- a row, or none -- while every column of the view stays the
one it was. The lineage of the
+ * view's columns does not reach it, so the scope the subquery became is
read here, expression by
+ * expression, the way the lineage is read for the view's own.
+ *
+ * <p>Two things are read. One is a value the subquery itself names -- an
expression of its own under one
+ * of these names, rather than a column of a table -- because that is what
a name the change takes away
+ * falls back to, and it decides the rows whether the subquery is a
predicate or a value. It is read only
+ * where the table the change is about is one that subquery reads: what a
name falls back to is what the
+ * scope that answered for it holds, so a scope that does not read the
table holds nothing for the name
+ * and one of its own is one the change never reached. The other is a
column of the table the change is
+ * about, which decides the rows only when the subquery's output is one
the query reads: an EXISTS tests
+ * the rows of its subquery and not what it projects, so a name it
projects and never compares is one
+ * this view's rows do not depend on.
+ *
+ * <p>Each scope is read on its own. A subquery inside one of these is a
scope of its own, and it is
+ * judged where it is read and not as a part of its enclosing one: its
projection is held only where
+ * that scope's own output is read, so an EXISTS inside an IN is still not
compared by the IN.
+ */
+ private static boolean reachesAnyColumnOfASubquery(Plan plan, Set<String>
names,
+ BaseTableInfo baseTableInfo) {
+ for (LogicalApply<?, ?> apply :
plan.<LogicalApply>collectToList(LogicalApply.class::isInstance)) {
+ List<Plan> itsOwnScope = ownScopeOf(apply);
+ boolean outputDecidesRows = !apply.isExist();
+ boolean isOneOfItsTables = readsAnyTableOf(itsOwnScope,
baseTableInfo);
+ for (Plan node : itsOwnScope) {
+ for (Expression expression : node.getExpressions()) {
+ if ((isOneOfItsTables &&
readsAnyNameTheSubqueryAnswersFor(expression, names))
+ || (outputDecidesRows &&
reachesAnyColumn(expression, names, baseTableInfo))) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The nodes of a subquery that belong to the scope this Apply stands for:
its right side, with the
+ * right side of every subquery nested in it left out.
+ *
+ * <p>A nested Apply is a scope of its own and is read as one, so its
right side belongs to that read
+ * rather than to this one; the relation it is asked about is the
enclosing scope's own and stays.
+ */
+ private static List<Plan> ownScopeOf(LogicalApply<?, ?> apply) {
+ List<Plan> itsOwnScope = Lists.newArrayList();
+ collectItsOwnScope((Plan) apply.right(), itsOwnScope);
+ return itsOwnScope;
+ }
+
+ private static void collectItsOwnScope(Plan node, List<Plan> itsOwnScope) {
+ itsOwnScope.add(node);
+ if (node instanceof LogicalApply) {
+ collectItsOwnScope(((LogicalApply<?, ?>) node).left(),
itsOwnScope);
+ return;
+ }
+ for (Plan child : node.children()) {
+ collectItsOwnScope(child, itsOwnScope);
+ }
+ }
+
+ /** Whether one of these nodes reads this table, through whatever views
stand between the two. */
+ private static boolean readsAnyTableOf(List<Plan> itsOwnScope,
BaseTableInfo baseTableInfo) {
+ return itsOwnScope.stream().anyMatch(node -> node instanceof
LogicalCatalogRelation
+ && new BaseTableInfo(((LogicalCatalogRelation)
node).getTable()).equals(baseTableInfo));
+ }
+
+ /**
+ * Whether this expression reads a value the subquery answers for itself,
under one of these names: a
+ * slot of the subquery's own -- an alias or a value it computed -- rather
than a column of a table.
+ *
+ * <p>Read rather than merely named, because an expression of the subquery
carrying one of these names
+ * says nothing on its own: a subquery that names a `flag` of its own
while no reference in it resolves
+ * to that name is one whose rows the change cannot reach, and one that
reads the name it names is where
+ * a reference that answered for the changed column falls back to.
+ */
+ private static boolean readsAnyNameTheSubqueryAnswersFor(Expression
expression, Set<String> names) {
+ for (Slot slot : expression.getInputSlots()) {
+ if (names.contains(slot.getName()) && !isColumnOfATable(slot)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** Whether this slot is a column of some table, or a value produced
inside the query. */
+ private static boolean isColumnOfATable(Slot slot) {
+ return slot instanceof SlotReference && ((SlotReference)
slot).getOriginalTable().isPresent();
+ }
+
+ /** Whether this expression reads a column of one of these names from this
table. */
+ private static boolean reachesAnyColumn(Expression expression, Set<String>
names,
+ BaseTableInfo baseTableInfo) {
+ return reachesAnyColumn(ImmutableList.of(expression), names,
baseTableInfo);
+ }
+
+ /**
+ * Whether any of these expressions reads a column of one of these names
from this table.
+ *
+ * <p>The name is the one the table gives the column, not the one the slot
is called: an alias renames
+ * what a slot is known by without moving the column it comes from, so a
slot named after a column it
+ * does not read is not reading that column, and a slot renamed to
something else is still reading its
+ * own. A change is a change to the column under the name the table gives
it, so this is the name the
+ * change is held against.
+ */
+ private static boolean reachesAnyColumn(Collection<? extends Expression>
expressions, Set<String> names,
+ BaseTableInfo baseTableInfo) {
+ for (Expression expression : expressions) {
+ for (Slot slot : expression.getInputSlots()) {
+ if (isColumnOf(slot, baseTableInfo) &&
names.contains(nameOfTheColumnItReads(slot))) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /** The name the table gives the column this slot reads, whatever the slot
itself is called. */
+ private static String nameOfTheColumnItReads(Slot slot) {
+ if (!(slot instanceof SlotReference)) {
+ return slot.getName();
+ }
+ return ((SlotReference)
slot).getOriginalColumn().map(Column::getName).orElseGet(slot::getName);
+ }
+
+ /**
+ * Whether the query resolves a column of one of these names across a
scope boundary, which is a name
+ * the change can move whatever the query writes it against.
+ */
+ private static boolean reachesAnyColumnAcrossScopes(Plan plan, LineageInfo
lineage, Set<String> names,
+ BaseTableInfo baseTableInfo) {
+ // A name is only one the change can move if the table it is about is
one the query reads at all.
+ boolean isOneOfItsTables = lineage.getTableLineageSet().stream()
+ .anyMatch(table -> new
BaseTableInfo(table).equals(baseTableInfo));
+ if (!isOneOfItsTables) {
+ return false;
+ }
+ return plan.anyMatch(node -> node instanceof LogicalApply
+ && ((LogicalApply<?, ?>) node).getCorrelationSlot().stream()
+ .anyMatch(slot -> names.contains(slot.getName())));
+ }
+
+ /**
+ * An MV's query is only as good as the base table schema it was analyzed
against, and a query that
+ * analyses is not enough on its own: a name it reaches a column by can
move to another column, and the
+ * table can move on between the analysis and the answer. 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.
*
@@ -385,18 +600,23 @@ public class MTMVRelationManager implements
MTMVHookService {
* rows computed under the old column epoch. Invalidating the MV is what
keeps that from being
* reported as current.
*
+ * <p>The check is the criterion, not just the reason for the record: a
column the query does not name
+ * is one this change leaves the MV's rows alone for, so nothing is
invalidated for it. It is a whole
+ * query that is analysed, not a column that is looked up: what the MV can
no longer be computed from
+ * is what the analysis refuses, wherever in the query it stood.
+ *
* <p>Every MV is checked, not only an IVM one: whether the query still
analyzes is a property of
* the MV and of the base table it reads, not of how the MV refreshes, and
the invalidation is the
* same one a change to that table records. What an IVM MV has on top of
it is a per-partition
* requirement, and that is decided elsewhere, from a query that analyzed.
*
- * @return whether the MV was invalidated. That is the whole record for
this change: the invalidation
- * carries the reason, and the caller has nothing left to write --
a second record would land
- * on the same state, and MTMVStatus#updateStateAndDetail would
overwrite the detail with the
- * blunter "the base table has been updated", which is what
knowing the query is unusable is
- * for. It would also bump the version and drop the snapshot twice
for one change.
+ * @return whether the MV was invalidated. False is the answer for a
change that reaches neither the
+ * query nor the rows it computed, and it is the whole record for
that change: there is nothing to
+ * write, and writing the generic "the base table has been
updated" anyway would stand for a
+ * rebuild the MV does not owe.
*/
- private boolean invalidateMvIfQueryUnusable(BaseTableInfo baseTableInfo,
Table mvTable) {
+ private boolean invalidateMvUnlessQueryHolds(BaseTableInfo baseTableInfo,
Table mvTable,
+ QueryJudgedChange queryJudgedChange) {
if (!(mvTable instanceof MTMV)) {
return false;
}
@@ -404,9 +624,10 @@ public class MTMVRelationManager implements
MTMVHookService {
// 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.
+ MTMVAnalyzeQueryInfo analyzedQueryInfo;
ConnectContext previousCtx = ConnectContext.get();
try {
- MTMVPlanUtil.ensureMTMVQueryUsable(mtmv,
+ analyzedQueryInfo = MTMVPlanUtil.ensureMTMVQueryUsable(mtmv,
MTMVPlanUtil.createMTMVContext(mtmv,
MTMVPlanUtil.DISABLE_RULES_WHEN_RUN_MTMV_TASK));
} catch (Exception e) {
LOG.info("Invalidate MV, the MV query is no longer usable.
baseTable={}, mtmv={}, reason={}",
@@ -420,6 +641,38 @@ public class MTMVRelationManager implements
MTMVHookService {
ConnectContext.remove();
}
}
+ // A query that still analyses has not necessarily kept its meaning: a
name can move. The column a
+ // query reaches a name by is the nearest one to it in the query's
scopes, so a column the change
+ // takes away leaves the name to whatever else answers to it -- an
unqualified name inside a subquery
+ // falls back to a correlated outer one, or to one of a table joined
there -- and a column the change
+ // adds can answer for the name from then on. Either way the query
produces the columns it always
+ // produced while their rows come from elsewhere, and the view's rows
are no longer the ones the
+ // query computes. What the query reads is read from the analysed
query's lineage, which names the
+ // columns the query really reaches -- through its projections,
filters, joins and aggregation, and
+ // through whatever views stand between them -- so an alias or a
string that happens to read the same
+ // is not one of them, and one reached inside a view is.
+ if (reachesAnyColumnOf(analyzedQueryInfo.getAnalyzedPlan(),
baseTableInfo, queryJudgedChange.columns())) {
+ LOG.info("Invalidate MV, the MV query reads a column the change is
about. "
+ + "baseTable={}, columns={}, mtmv={}",
baseTableInfo, queryJudgedChange.columns(),
+ mtmv.getName());
+ mtmv.invalidateWholeMv("The MV query reads a column the change is
about: " + baseTableInfo)
+ .await();
+ return true;
+ }
+ // And the answer has to be about the table the change left. The
analysis reads the table as it is
+ // now, and a light change is applied before this hook runs, so a
table that has moved on since --
+ // a column taken away and added back under the same name, say --
would have been analysed in that
+ // later state: the same shapes, a different column. Asked of the
change itself rather than of the
+ // columns, because what the table should hold is what that change
asked for, and the answer is the
+ // one the analysis was given about only while it still holds.
+ if (!queryJudgedChange.hasReachedTheTable()) {
+ LOG.info("Invalidate MV, the table is no longer the one the change
was applied to. "
+ + "baseTable={}, columns={}, mtmv={}",
baseTableInfo, queryJudgedChange.columns(),
+ mtmv.getName());
+ mtmv.invalidateWholeMv("The table is no longer the one the change
was applied to: "
+ + baseTableInfo).await();
+ return true;
+ }
return false;
}
@@ -483,13 +736,14 @@ public class MTMVRelationManager implements
MTMVHookService {
}
/**
- * Puts every MV that reads this base table into {@code SCHEMA_CHANGE}.
+ * Puts every MV that reads this base table into {@code SCHEMA_CHANGE} --
or, for the changes that ask
+ * for it, every MV that change does not leave alone.
*
- * @param checkQueryUsable whether to re-analyze each MV's query first; see
- * {@link #invalidateMvIfQueryUnusable}
+ * @param queryJudgedChange the change, left to each MV's own query, or
null when the alter is not one a
+ * query decides; see {@link
#invalidateMvUnlessQueryHolds}
*/
private void processBaseTableChange(BaseTableInfo baseTableInfo, String
msgPrefix,
- boolean checkQueryUsable) {
+ QueryJudgedChange queryJudgedChange) {
Set<BaseTableInfo> mtmvsByBaseTable =
getMtmvsByBaseTableOneLevelAndFromView(baseTableInfo);
if (CollectionUtils.isEmpty(mtmvsByBaseTable)) {
return;
@@ -502,10 +756,20 @@ public class MTMVRelationManager implements
MTMVHookService {
LOG.warn(e);
continue;
}
- if (checkQueryUsable && invalidateMvIfQueryUnusable(baseTableInfo,
mvTable)) {
- // Invalidated with the reason, which is the more specific of
the two messages and the one
- // this change is worth recording: the state is the same one
the generic record below
- // would set, so writing it too would only bury the reason.
+ if (queryJudgedChange != null) {
+ // The change is left to each view's own query, which is asked
and whose answer is held to
+ // the two things that can move under it -- see
invalidateMvUnlessQueryHolds. A view the
+ // change leaves alone is not invalidated: that would discard
the result of a refresh
+ // running against it on the strength of a change that never
reached it. Nothing else is
+ // recorded here either -- the invalidation carries its
reason, and a second record would
+ // land on the same state with the blunter "the base table has
been updated", having bumped
+ // the version and dropped the snapshot a second time for one
change.
+ if (invalidateMvUnlessQueryHolds(baseTableInfo, mvTable,
queryJudgedChange)) {
+ LOG.info("Invalidated MV, baseTable={}, mv={}",
baseTableInfo, mvTable.getName());
+ } else {
+ LOG.info("The change leaves the MV alone, nothing to
invalidate. baseTable={}, mv={}",
+ baseTableInfo, mvTable.getName());
+ }
continue;
}
if (!(mvTable instanceof MTMV)) {
diff --git a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVService.java
b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVService.java
index c9fb6005793..85fa48f6edb 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVService.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVService.java
@@ -113,13 +113,15 @@ public class MTMVService implements EventListener {
}
}
- public void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace) {
+ public void alterTable(BaseTableInfo oldTableInfo, Optional<BaseTableInfo>
newTableInfo, boolean isReplace,
+ MTMVHookService.QueryJudgedChange queryJudgedChange) {
Objects.requireNonNull(oldTableInfo, "oldTableInfo can not be null");
Objects.requireNonNull(newTableInfo, "newTableInfo can not be null");
- LOG.info("alterTable, oldTableInfo: {}, newTableInfo: {}, isReplace:
{}", oldTableInfo, newTableInfo,
- isReplace);
+ LOG.info("alterTable, oldTableInfo: {}, newTableInfo: {}, isReplace:
{}, queryJudgedColumns: {}",
+ oldTableInfo, newTableInfo, isReplace,
+ queryJudgedChange == null ? null :
queryJudgedChange.columns());
for (MTMVHookService mtmvHookService : hooks.values()) {
- mtmvHookService.alterTable(oldTableInfo, newTableInfo, isReplace);
+ mtmvHookService.alterTable(oldTableInfo, newTableInfo, isReplace,
queryJudgedChange);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AddColumnOp.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AddColumnOp.java
index eaef1483036..ac034bb2c06 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AddColumnOp.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AddColumnOp.java
@@ -36,6 +36,7 @@ import org.apache.doris.qe.ConnectContext;
import com.google.common.collect.Sets;
+import java.util.Collections;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -119,7 +120,29 @@ public class AddColumnOp extends AlterTableOp {
@Override
public boolean needChangeMTMVState() {
- return false;
+ // The name the column adds can be the one a view's query reaches a
column by, and the column it
+ // reaches is the nearest one in the query's scopes: one added there
answers for the name from then
+ // on, and the rows the view holds are the ones of the column that
used to. That is the view's query
+ // to judge, see {@link AlterOp#queryJudgedColumnNames}.
+ return true;
+ }
+
+ @Override
+ public boolean hasReachedTheTable(OlapTable table) {
+ // The column has to be in the table: a change that is not a light one
is applied by a job, and a
+ // query judged before that job has run is judged against the table
from before the change -- which
+ // is the one that cannot be answered about at all.
+ for (String columnName : queryJudgedColumnNames()) {
+ if (table.getColumn(columnName) == null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public Set<String> queryJudgedColumnNames() {
+ return Collections.singleton(getColumnDef().getName());
}
@Override
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AddColumnsOp.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AddColumnsOp.java
index 9ff326ffb38..086d61bbe97 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AddColumnsOp.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AddColumnsOp.java
@@ -27,10 +27,12 @@ import org.apache.doris.common.UserException;
import org.apache.doris.qe.ConnectContext;
import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Set;
/**
* AddColumnsOp
@@ -108,7 +110,31 @@ public class AddColumnsOp extends AlterTableOp {
@Override
public boolean needChangeMTMVState() {
- return false;
+ // As in {@link AddColumnOp}: a name a view's query reaches a column
by can be taken over by one of
+ // these, and that is the view's query to judge.
+ return true;
+ }
+
+ @Override
+ public boolean hasReachedTheTable(OlapTable table) {
+ // The column has to be in the table: a change that is not a light one
is applied by a job, and a
+ // query judged before that job has run is judged against the table
from before the change -- which
+ // is the one that cannot be answered about at all.
+ for (String columnName : queryJudgedColumnNames()) {
+ if (table.getColumn(columnName) == null) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @Override
+ public Set<String> queryJudgedColumnNames() {
+ Set<String> names = Sets.newHashSetWithExpectedSize(columns.size());
+ for (Column column : columns) {
+ names.add(column.getName());
+ }
+ return names;
}
@Override
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVRenameInfo.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVRenameInfo.java
index 43cd1447e76..e91e50b8870 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVRenameInfo.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVRenameInfo.java
@@ -73,6 +73,6 @@ public class AlterMTMVRenameInfo extends AlterMTMVInfo {
BaseTableInfo oldTableInfo = new BaseTableInfo(table);
Env.getCurrentEnv().renameTable(db, table, newName.getTbl());
BaseTableInfo newTableInfo = new BaseTableInfo(table);
- Env.getCurrentEnv().getMtmvService().alterTable(oldTableInfo,
Optional.of(newTableInfo), false);
+ Env.getCurrentEnv().getMtmvService().alterTable(oldTableInfo,
Optional.of(newTableInfo), false, null);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVReplaceInfo.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVReplaceInfo.java
index 3467b60015c..fcd315494f5 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVReplaceInfo.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterMTMVReplaceInfo.java
@@ -94,6 +94,6 @@ public class AlterMTMVReplaceInfo extends AlterMTMVInfo {
MTMV newMtmv = (MTMV) db.getTableOrDdlException(newName,
TableType.MATERIALIZED_VIEW);
Env.getCurrentEnv().getAlterInstance().processReplaceTable(db, mtmv,
newName, swapTable, true);
Env.getCurrentEnv().getMtmvService()
- .alterTable(new BaseTableInfo(mtmv), Optional.of(new
BaseTableInfo(newMtmv)), true);
+ .alterTable(new BaseTableInfo(mtmv), Optional.of(new
BaseTableInfo(newMtmv)), true, null);
}
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterOp.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterOp.java
index b77d8386744..4d0639d1fc8 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterOp.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/AlterOp.java
@@ -18,10 +18,13 @@
package org.apache.doris.nereids.trees.plans.commands.info;
import org.apache.doris.alter.AlterOpType;
+import org.apache.doris.catalog.OlapTable;
import org.apache.commons.lang3.NotImplementedException;
+import java.util.Collections;
import java.util.Map;
+import java.util.Set;
/**
* AlterOp
@@ -42,6 +45,45 @@ public abstract class AlterOp {
public abstract boolean needChangeMTMVState();
+ /**
+ * The columns this operation gives the table or takes away from it, for
the operations whose effect on
+ * a view is decided by that view's own query; empty for every operation
whose effect the query does not
+ * decide.
+ *
+ * <p>Marked on the operations whose only way of reaching a view is
through a column, in either
+ * direction. A column that is dropped or renamed is either one the view's
query reaches -- and then the
+ * view cannot be computed from the table as it now stands, or is computed
from another column that
+ * answers to the same name -- or it is one the query never reaches, and
the view's rows are the ones
+ * they already were. A column that is added can move a name the query
reaches: the name is answered by
+ * the column nearest to it in the query's scopes, so one added there
takes it away from wherever the
+ * query reached it. A type change is neither: the query keeps analysing,
and re-analysis compares the
+ * columns the query produces, so a column the query only filters or joins
on can change what the view's
+ * rows mean while re-analysis reports nothing.
+ *
+ * <p>The names are what the judgement is about, rather than the operation
as a whole: they are the
+ * columns the view's query is asked about, and what the answer is checked
against once it has been
+ * given. See {@code MTMVRelationManager}, which asks the query and holds
the answer to these names.
+ *
+ * <p>Default empty: the operation invalidates every view that reads the
table.
+ */
+ public Set<String> queryJudgedColumnNames() {
+ return Collections.emptySet();
+ }
+
+ /**
+ * Whether the change this operation asks for has reached the table yet.
+ *
+ * <p>It is what a judgement about the materialized views that read the
table has to wait for. A schema
+ * change is applied by a job unless it is a light one, and the hook that
judges the views runs where
+ * such a job may not have run yet -- the table still holds the column the
change takes away, and
+ * re-analysing a view's query against it would answer for the table from
before the change.
+ *
+ * <p>Default true: an operation whose effect is in the statement itself
has nothing to wait for.
+ */
+ public boolean hasReachedTheTable(OlapTable table) {
+ return true;
+ }
+
// Whether this alter operation is allowed on tables that enable row
binlog.
// Default is false, and only operations that are explicitly marked as safe
// for row binlog should override this to return true.
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/BaseViewInfo.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/BaseViewInfo.java
index 3a0d8263a28..7e2459fe1ba 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/BaseViewInfo.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/BaseViewInfo.java
@@ -60,6 +60,7 @@ import
org.apache.doris.nereids.trees.plans.logical.LogicalHaving;
import org.apache.doris.nereids.trees.plans.logical.LogicalJoin;
import org.apache.doris.nereids.trees.plans.logical.LogicalPlan;
import org.apache.doris.nereids.trees.plans.logical.LogicalProject;
+import org.apache.doris.nereids.trees.plans.logical.LogicalQualify;
import org.apache.doris.nereids.trees.plans.logical.LogicalRepeat;
import org.apache.doris.nereids.trees.plans.logical.LogicalSort;
import org.apache.doris.nereids.trees.plans.logical.LogicalTopN;
@@ -392,6 +393,14 @@ public class BaseViewInfo {
return project.child().accept(this, context);
}
+ @Override
+ public Void visitLogicalQualify(LogicalQualify<? extends Plan>
qualify, StatementContext context) {
+ for (Expression expr : qualify.getConjuncts()) {
+ expr.accept(SlotDealer.INSTANCE, context);
+ }
+ return qualify.child().accept(this, context);
+ }
+
@Override
public Void visitLogicalRepeat(LogicalRepeat<? extends Plan> repeat,
StatementContext context) {
for (Expression expr : repeat.getOutputExpressions()) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/DropColumnOp.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/DropColumnOp.java
index f9a3444cbe0..13278345ef0 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/DropColumnOp.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/DropColumnOp.java
@@ -35,6 +35,7 @@ import org.apache.doris.qe.ConnectContext;
import com.google.common.base.Strings;
+import java.util.Collections;
import java.util.Map;
import java.util.Set;
@@ -172,6 +173,19 @@ public class DropColumnOp extends AlterTableOp {
return true;
}
+ @Override
+ public Set<String> queryJudgedColumnNames() {
+ return Collections.singleton(getColName());
+ }
+
+ @Override
+ public boolean hasReachedTheTable(OlapTable table) {
+ // A path into a column leaves the column itself in place, so what the
table holds is the container
+ // of the part this asks about: dropping the part cannot be told apart
from a change that has not
+ // been applied, and that is the answer which keeps the views
invalidated.
+ return !columnPath.isNested() && table.getColumn(getColName()) == null;
+ }
+
@Override
public boolean allowOpRowBinlog() {
// Drop column is considered safe for row binlog tables after whitelist
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/RenameColumnOp.java
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/RenameColumnOp.java
index 15270360535..bd3374aeaba 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/RenameColumnOp.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/info/RenameColumnOp.java
@@ -20,6 +20,7 @@ package org.apache.doris.nereids.trees.plans.commands.info;
import org.apache.doris.alter.AlterOpType;
import org.apache.doris.analysis.ColumnPath;
import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.OlapTable;
import org.apache.doris.common.AnalysisException;
import org.apache.doris.common.FeNameFormat;
import org.apache.doris.common.UserException;
@@ -27,8 +28,10 @@ import org.apache.doris.common.util.SqlUtils;
import org.apache.doris.qe.ConnectContext;
import com.google.common.base.Strings;
+import com.google.common.collect.Sets;
import java.util.Map;
+import java.util.Set;
/**
* RenameColumnOp
@@ -97,6 +100,21 @@ public class RenameColumnOp extends AlterTableOp {
return true;
}
+ @Override
+ public Set<String> queryJudgedColumnNames() {
+ // Both names: the column the query used to reach under the old one,
and the name the new one takes
+ // over, which a query that reaches that name from anywhere else can
be bound to from now on.
+ return Sets.newHashSet(getColName(), newColName);
+ }
+
+ @Override
+ public boolean hasReachedTheTable(OlapTable table) {
+ // The name the query spells is what a rename takes away, so a table
that no longer has it is one
+ // the change has reached -- the question a drop asks as well. A path
into a column is the case this
+ // cannot answer for, as it is for a drop.
+ return !columnPath.isNested() && table.getColumn(getColName()) == null;
+ }
+
@Override
public String toSql() {
return "RENAME COLUMN " + columnPath.toSql() + " " +
SqlUtils.getIdentSql(newColName);
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRelationManagerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRelationManagerTest.java
index b58137c9564..5109d399fb1 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRelationManagerTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/mtmv/MTMVRelationManagerTest.java
@@ -188,7 +188,8 @@ public class MTMVRelationManagerTest {
* a concurrent task result, leaving the follower in SCHEMA_CHANGE where
this FE ended NORMAL.
*
* <p>A rename is the shortest way into this path: it skips the query
check, so what the hook does is
- * exactly the record under test.
+ * exactly the record under test. The rename is passed with the check
asked for, which it cannot be
+ * judged by -- the MV query keeps spelling the old name -- and the flag
does not reach it.
*/
@Test
public void testABaseTableRenameIsRecordedThroughTheInvalidation() {
@@ -201,7 +202,8 @@ public class MTMVRelationManagerTest {
try (MockedStatic<MTMVUtil> util = Mockito.mockStatic(MTMVUtil.class))
{
util.when(() ->
MTMVUtil.getTable(Mockito.any(BaseTableInfo.class))).thenReturn(mtmv);
- manager.alterTable(t3, Optional.of(t4), false);
+ // A rename of the table names no column, so the alter does not
hand the judgement to a query.
+ manager.alterTable(t3, Optional.of(t4), false, null);
}
ArgumentCaptor<String> detail = ArgumentCaptor.forClass(String.class);
@@ -210,4 +212,29 @@ public class MTMVRelationManagerTest {
detail.getValue());
Mockito.verify(editLogItem).await();
}
+
+ /**
+ * A change the alter does not hand to the queries is invalidated as a
base table change was before the
+ * queries were asked at all. The alter says so by naming no column: it
does that where every clause of
+ * it is not one a query decides, and where the schema change has not
reached the table yet -- asking
+ * then would answer for the table from before the change.
+ */
+ @Test
+ public void testAChangeTheQueryCannotBeAskedAboutInvalidates() {
+ MTMVRelationManager manager = new MTMVRelationManager();
+ manager.refreshMTMVCache(new MTMVRelation(Sets.newHashSet(t3),
Sets.newHashSet(t3),
+ Sets.newHashSet(t3), Sets.newHashSet(), Sets.newHashSet()),
mv1);
+ MTMV mtmv = Mockito.mock(MTMV.class);
+ EditLogItem editLogItem = Mockito.mock(EditLogItem.class);
+
Mockito.when(mtmv.invalidateWholeMv(Mockito.anyString())).thenReturn(editLogItem);
+ try (MockedStatic<MTMVUtil> util = Mockito.mockStatic(MTMVUtil.class))
{
+ util.when(() ->
MTMVUtil.getTable(Mockito.any(BaseTableInfo.class))).thenReturn(mtmv);
+
+ // The same table on both sides: this is not a rename, so what the
alter names alone decides.
+ manager.alterTable(t3, Optional.of(t3), false, null);
+ }
+
+ Mockito.verify(mtmv).invalidateWholeMv(Mockito.anyString());
+ Mockito.verify(editLogItem).await();
+ }
}
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 7f6b583b8d8..0cf0f85a286 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
@@ -17,6 +17,7 @@
package org.apache.doris.mtmv.ivm;
+import org.apache.doris.analysis.ColumnPath;
import org.apache.doris.catalog.Database;
import org.apache.doris.catalog.Env;
import org.apache.doris.catalog.MTMV;
@@ -43,6 +44,8 @@ import org.apache.doris.mtmv.MTMVRefreshEnum.MTMVState;
import org.apache.doris.mtmv.MTMVRefreshPartitionSnapshot;
import org.apache.doris.mtmv.MTMVRelation;
import org.apache.doris.mtmv.MTMVStatus;
+import org.apache.doris.nereids.trees.plans.commands.info.DropColumnOp;
+import org.apache.doris.nereids.trees.plans.commands.info.RenameColumnOp;
import org.apache.doris.persist.AlterMTMV;
import org.apache.doris.persist.DropPartitionInfo;
import org.apache.doris.persist.EditLog;
@@ -88,6 +91,54 @@ public class IvmBaselineRebuildTest extends
TestWithFeService {
Assertions.assertEquals(MTMVState.SCHEMA_CHANGE,
getMtmv(db).getStatus().getState());
}
+ /**
+ * A truncated partition is placed on the MV partitions that read it, the
way a dropped partition is:
+ * the requirement is what carries the change, not the state, and every
other partition keeps catching
+ * up incrementally.
+ */
+ @Test
+ public void testTruncateAPartitionStaysOnThePartitionsThatReadIt() throws
Exception {
+ String db = "ivm_truncate_one_partition";
+ createPartitionedIvmTableAndPartitionedMv(db);
+ MTMV mtmv = getMtmv(db);
+ Set<String> expected = mvPartitionsWithSameRange(mtmv,
getBaseTable(db), "p202001");
+ Assertions.assertEquals(1, expected.size());
+
+ alignStatesOf(mtmv);
+ executeSql("TRUNCATE TABLE ivm_base PARTITION(p202001)");
+
+ Assertions.assertNotEquals(MTMVState.SCHEMA_CHANGE,
mtmv.getStatus().getState());
+ for (String partitionName : mtmv.getPartitionNames()) {
+ long expectedLatest = expected.contains(partitionName) ? 2 : 1;
+ Assertions.assertEquals(expectedLatest,
+
mtmv.getPartitionStates().get(partitionName).getLatestEpoch());
+ }
+ }
+
+ /**
+ * An entire-table truncate is recorded the same way, and this is the case
that says the scope of the
+ * change is not what decides: every MV partition reads a partition that
was replaced, so every one of
+ * them has its requirement raised -- and the state, which is the coarser
record, is not what carries
+ * it. Recording it as a whole-MV change instead would send every
partition of the MV to a rebuild and
+ * reset the streams, for a change whose scope the per-partition
requirement already expresses.
+ */
+ @Test
+ public void testTruncateTableStaysOnThePartitionsThatReadIt() throws
Exception {
+ String db = "ivm_truncate_entire_table";
+ createPartitionedIvmTableAndPartitionedMv(db);
+ MTMV mtmv = getMtmv(db);
+ Assertions.assertEquals(2, mtmv.getPartitionNames().size());
+
+ alignStatesOf(mtmv);
+ executeSql("TRUNCATE TABLE ivm_base");
+
+ Assertions.assertNotEquals(MTMVState.SCHEMA_CHANGE,
mtmv.getStatus().getState());
+ for (String partitionName : mtmv.getPartitionNames()) {
+ Assertions.assertEquals(2,
+
mtmv.getPartitionStates().get(partitionName).getLatestEpoch());
+ }
+ }
+
@Test
public void testTruncatePartitionMarksBaselineRebuild() throws Exception {
String db = "ivm_broken_truncate_partition";
@@ -130,17 +181,19 @@ public class IvmBaselineRebuildTest extends
TestWithFeService {
createPartitionedIvmTableAndMv(db);
MTMV mtmv = getMtmv(db);
- // ivm_mv selects dt, k1, v1. Dropping a column it does not use must
leave the baseline alone: no
- // partition's requirement is raised, so no partition is sent to a
rebuild. The MV state is not the
- // witness here -- a column change puts any MV into SCHEMA_CHANGE
through the shared base-table hook,
- // IVM or not (see testRenameTableMarksBaselineRebuild), so telling a
referenced column from an
- // unreferenced one is that hook's criterion to refine and not this
one's.
+ // ivm_mv selects dt, k1, v1. Dropping a column it does not use leaves
the MV where it is: no
+ // partition's requirement is raised, and the MV is not invalidated
either. The criterion is the
+ // MV's own query -- a column the query does not name leaves it
analyzable, so there is no rebuild
+ // for the state to stand for, and writing one would discard the
result of a refresh running
+ // against rows that are still the rows they should be.
alignStatesOf(mtmv);
+ long versionBefore = mtmv.getSchemaChangeVersion();
Map<String, Long> before = latestEpochsOf(mtmv);
executeSql("ALTER TABLE ivm_base ADD COLUMN spare int");
executeSql("ALTER TABLE ivm_base DROP COLUMN spare");
Assertions.assertEquals(before, latestEpochsOf(mtmv),
"a column the MV does not use must not raise any partition's
requirement");
+ assertNotInvalidated(mtmv, versionBefore, "a column the MV does not
use must not invalidate the MV");
// 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
@@ -224,19 +277,204 @@ public class IvmBaselineRebuildTest extends
TestWithFeService {
MTMV mtmv = getMtmv(db);
Assertions.assertFalse(mtmv.isIvm());
- // A column this MV does not read leaves its query alone, so the MV is
put into SCHEMA_CHANGE for
- // the ordinary reason -- which is what the shared hook does for any
column change, IVM or not --
- // and not because its query went away. The detail is what tells the
two apart here, because the
- // state is the same either way.
+ // A column this MV does not read leaves its query alone, so the MV is
left alone too, IVM or not:
+ // the state is a rebuild the next refresh owes, and there is none to
owe. What an IVM MV has on top
+ // of the state is its epoch state, and that stays where it was as
well.
+ long versionBefore = mtmv.getSchemaChangeVersion();
executeSql("ALTER TABLE ivm_base ADD COLUMN spare int");
executeSql("ALTER TABLE ivm_base DROP COLUMN spare");
+ assertNotInvalidated(mtmv, versionBefore, "a column the MV does not
read must not invalidate it");
+
+ // A column it does read takes the query away, and that is what the MV
is invalidated with.
+ executeSql("ALTER TABLE ivm_base DROP COLUMN v1");
+ Assertions.assertEquals(MTMVState.SCHEMA_CHANGE,
mtmv.getStatus().getState());
+ assertUnanalyzableDetail(mtmv);
+ }
+
+ /**
+ * A renamed column is judged the same way a dropped one is, and the
operation says so for itself: a row
+ * binlog table refuses a column rename outright, so this is a plain MV's
path, and the re-analysis is
+ * the answer for it -- the query names the old name, or it does not name
the column at all.
+ */
+ @Test
+ public void testARenamedColumnIsJudgedByTheQuery() throws Exception {
+ String db = "ivm_renamed_column";
+ createPartitionedTableWithoutRowBinlog(db);
+ createMvByNereids("CREATE MATERIALIZED VIEW ivm_mv\n"
+ + "BUILD DEFERRED REFRESH COMPLETE ON MANUAL\n"
+ + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+ + "PROPERTIES ('replication_num' = '1')\n"
+ + "AS SELECT dt, k1, v1 FROM ivm_base");
+ MTMV mtmv = getMtmv(db);
+ Assertions.assertFalse(mtmv.isIvm());
+
+ executeSql("ALTER TABLE ivm_base ADD COLUMN spare int");
+ long versionBefore = mtmv.getSchemaChangeVersion();
+ executeSql("ALTER TABLE ivm_base RENAME COLUMN spare spare_renamed");
+ assertNotInvalidated(mtmv, versionBefore, "a column the MV does not
read must not invalidate it");
+
+ executeSql("ALTER TABLE ivm_base RENAME COLUMN v1 v1_renamed");
+ Assertions.assertEquals(MTMVState.SCHEMA_CHANGE,
mtmv.getStatus().getState());
+ assertUnanalyzableDetail(mtmv);
+ }
+
+ /**
+ * A column change is only judged by the queries once the table has it.
The query is analysed against the
+ * table as it is when the hook runs, so a schema change that has not been
applied yet leaves the column
+ * in place and the answer would be about the table from before the change
-- which is why the alter asks
+ * the operation instead of trusting its own submission. Pinned here: the
answer flips when the change
+ * lands, and a path into a column is one the question cannot be answered
for.
+ */
+ @Test
+ public void testAColumnChangeIsJudgedOnlyOnceTheTableHasIt() throws
Exception {
+ String db = "ivm_column_change_reached";
+ createPartitionedTableWithoutRowBinlog(db);
+ OlapTable baseTable = getBaseTable(db);
+
+ RenameColumnOp pendingRename = new RenameColumnOp("v1", "v1_renamed");
+ Assertions.assertFalse(pendingRename.hasReachedTheTable(baseTable),
+ "the name the query spells is still in the table, so the
rename has not reached it");
+ DropColumnOp pendingDrop = new DropColumnOp("v1", null, null);
+ Assertions.assertFalse(pendingDrop.hasReachedTheTable(baseTable),
+ "the column is still in the table, so the drop has not reached
it");
+
+ executeSql("ALTER TABLE ivm_base RENAME COLUMN v1 v1_renamed");
+
+
Assertions.assertTrue(pendingRename.hasReachedTheTable(getBaseTable(db)),
+ "the old name is gone from the table, so the rename has
reached it");
+
+ DropColumnOp dropAfterRename = new DropColumnOp("v1_renamed", null,
null);
+
Assertions.assertFalse(dropAfterRename.hasReachedTheTable(getBaseTable(db)));
+ executeSql("ALTER TABLE ivm_base DROP COLUMN v1_renamed");
+
Assertions.assertTrue(dropAfterRename.hasReachedTheTable(getBaseTable(db)),
+ "the column is gone from the table, so the drop has reached
it");
+ Assertions.assertTrue(pendingDrop.hasReachedTheTable(getBaseTable(db)),
+ "and the name that drop asks about is one the table does not
have either");
+
+ // A path that addresses part of a column leaves the column itself in
place, so the question has no
+ // answer: the change is treated as one that has not reached the
table, which keeps the views
+ // invalidated rather than cleared on a look that cannot see the part.
+ DropColumnOp nestedDrop = new
DropColumnOp(ColumnPath.fromDotName("k1.leaf"), null, null);
+
Assertions.assertFalse(nestedDrop.hasReachedTheTable(getBaseTable(db)));
+ }
+
+ /**
+ * Changing a column's type is not judged by the query, and this is the
case that says why: the query
+ * still analyses -- it is projected or not, which is all the check
compares -- while the rows the MV
+ * already holds were computed under the old type. The operation
invalidates on its own then, whether or
+ * not the MV reads the column, which is what keeps a predicate that now
compares something else from
+ * being applied to rows that were filtered under the old meaning.
+ */
+ @Test
+ public void testATypeChangeInvalidatesWithoutAskingTheQuery() throws
Exception {
+ String db = "ivm_type_change";
+ createPartitionedTableWithoutRowBinlog(db);
+ createMvByNereids("CREATE MATERIALIZED VIEW ivm_mv\n"
+ + "BUILD DEFERRED REFRESH COMPLETE ON MANUAL\n"
+ + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+ + "PROPERTIES ('replication_num' = '1')\n"
+ + "AS SELECT dt, k1, v1 FROM ivm_base");
+ MTMV mtmv = getMtmv(db);
+ Assertions.assertFalse(mtmv.isIvm());
+
+ executeSql("ALTER TABLE ivm_base ADD COLUMN spare int");
+ executeSql("ALTER TABLE ivm_base MODIFY COLUMN spare bigint");
+
Assertions.assertEquals(MTMVState.SCHEMA_CHANGE,
mtmv.getStatus().getState());
+ // The change is recorded as the base-table change it is, not as a
query that went away: the query
+ // is still there, and the reason is what tells a reader which of the
two happened.
Assertions.assertFalse(
mtmv.getStatus().getSchemaChangeDetail().contains("no longer
analyzable"),
- "a column the MV does not read leaves the query analyzable,
was: "
- + mtmv.getStatus().getSchemaChangeDetail());
+ "the query is untouched by a type change, was: " +
mtmv.getStatus().getSchemaChangeDetail());
+ }
+
+ /**
+ * A subquery that answers for a name of its own is a dependency only
where the table being changed is
+ * one that subquery reads: what a name falls back to is what the scope
that used to answer for it
+ * holds, so a scope that never read the table holds nothing for it. Two
sibling subqueries are the
+ * shape -- the changed table is one only the first reads, and the `flag`
of the second is its own --
+ * where reading every subquery's own names reads a dependency the change
cannot have made.
+ */
+ @Test
+ public void testASubqueryTheChangeDoesNotReachIsNotADependency() throws
Exception {
+ String db = "ivm_column_change_sibling_scopes";
+ createDatabaseAndUse(db);
+ createTable("CREATE TABLE " + db + ".ivm_base (id int, flag int)
DUPLICATE KEY(id)\n"
+ + "DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES
('replication_num' = '1')");
+ createTable("CREATE TABLE " + db + ".ivm_other (u int) DUPLICATE
KEY(u)\n"
+ + "DISTRIBUTED BY HASH(u) BUCKETS 1 PROPERTIES
('replication_num' = '1')");
+ createTable("CREATE TABLE " + db + ".ivm_spare (id int) DUPLICATE
KEY(id)\n"
+ + "DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES
('replication_num' = '1')");
+ createMvByNereids("CREATE MATERIALIZED VIEW ivm_mv\n"
+ + "BUILD DEFERRED REFRESH COMPLETE ON MANUAL\n"
+ + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+ + "PROPERTIES ('replication_num' = '1')\n"
+ + "AS SELECT o.id FROM ivm_base o\n"
+ + "WHERE EXISTS (SELECT 1 FROM ivm_spare c WHERE c.id =
o.id)\n"
+ + " AND EXISTS (SELECT 1 AS flag, COUNT(*) AS n FROM
ivm_other u\n"
+ + " GROUP BY flag HAVING flag = 1)");
+ MTMV mtmv = getMtmv(db);
+
+ long versionBefore = mtmv.getSchemaChangeVersion();
+ executeSql("ALTER TABLE ivm_spare ADD COLUMN flag int default 0");
+ assertNotInvalidated(mtmv, versionBefore,
+ "a name a subquery names itself, where the changed table is
not one that subquery reads,"
+ + " must not invalidate the MV");
+ }
+
+ /**
+ * A subquery inside another one is judged by its own output being read:
an EXISTS is asked whether it
+ * has a row and never what it projects, and an IN around it reads its own
subquery, not the scopes
+ * inside it. Adding the column the inner projection is then read from
leaves every row of the query
+ * where it was, so the MV is left alone -- while a column the IN itself
compares is not.
+ */
+ @Test
+ public void
testAProjectionTheSubqueryAroundItDoesNotReadIsNotADependency() throws
Exception {
+ String db = "ivm_column_change_nested_scope";
+ createDatabaseAndUse(db);
+ createTable("CREATE TABLE " + db + ".ivm_base (id int, k int)
DUPLICATE KEY(id)\n"
+ + "DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES
('replication_num' = '1')");
+ createTable("CREATE TABLE " + db + ".ivm_mid (k int, id int, spare
int) DUPLICATE KEY(k)\n"
+ + "DISTRIBUTED BY HASH(k) BUCKETS 1 PROPERTIES
('replication_num' = '1')");
+ createTable("CREATE TABLE " + db + ".ivm_spare (id int) DUPLICATE
KEY(id)\n"
+ + "DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES
('replication_num' = '1')");
+ createMvByNereids("CREATE MATERIALIZED VIEW ivm_mv\n"
+ + "BUILD DEFERRED REFRESH COMPLETE ON MANUAL\n"
+ + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+ + "PROPERTIES ('replication_num' = '1')\n"
+ + "AS SELECT o.id FROM ivm_base o\n"
+ + "WHERE o.k IN (SELECT m.k FROM ivm_mid m\n"
+ + " WHERE EXISTS (SELECT spare FROM ivm_spare i WHERE i.id
= m.id))");
+ MTMV mtmv = getMtmv(db);
+
+ long versionBefore = mtmv.getSchemaChangeVersion();
+ executeSql("ALTER TABLE ivm_spare ADD COLUMN spare int default 0");
+ assertNotInvalidated(mtmv, versionBefore,
+ "a projection an EXISTS does not compare, inside a subquery
the IN around it reads the rows"
+ + " of, must not invalidate the MV");
+ }
+
+ /**
+ * A name in a view's own definition has to say which column it was bound
to, wherever it is written.
+ * `QUALIFY` reads a base table's column while that column is there and
whatever else answers for the
+ * name once it is not -- here the view's own `v1`, whose output is a
constant -- which changes the rows
+ * the query returns without changing the columns the view produces. The
judgement re-analyses this
+ * definition, so a name it never bound to a column is one it cannot hold
the change against, and the
+ * view is left holding rows the query no longer returns. Pinned as the
reason it is invalidated with:
+ * the query is gone, because the column it named is.
+ */
+ @Test
+ public void testAQualifyNameIsBoundToItsColumnInTheStoredDefinition()
throws Exception {
+ String db = "ivm_qualify_bound_name";
+ createPartitionedTableWithoutRowBinlog(db);
+ createMvByNereids("CREATE MATERIALIZED VIEW ivm_mv\n"
+ + "BUILD DEFERRED REFRESH COMPLETE ON MANUAL\n"
+ + "DISTRIBUTED BY RANDOM BUCKETS 1\n"
+ + "PROPERTIES ('replication_num' = '1')\n"
+ + "AS SELECT 1 AS v1, ROW_NUMBER() OVER (ORDER BY dt) AS rn
FROM ivm_base\n"
+ + "QUALIFY v1 = 1 AND rn = 1");
+ MTMV mtmv = getMtmv(db);
- // A column it does read takes the query away, and that is what the MV
is invalidated with.
executeSql("ALTER TABLE ivm_base DROP COLUMN v1");
Assertions.assertEquals(MTMVState.SCHEMA_CHANGE,
mtmv.getStatus().getState());
assertUnanalyzableDetail(mtmv);
@@ -1173,6 +1411,28 @@ public class IvmBaselineRebuildTest extends
TestWithFeService {
+ "PROPERTIES ('replication_num' = '1', 'binlog.enable' =
'true', 'binlog.format' = 'ROW')");
}
+ /**
+ * The same base table without row binlog. A row binlog table only accepts
the operations marked as safe
+ * for it, and a column rename or a type change is not one of them, so the
changes that need one of those
+ * are pinned against a plain table and a plain MV -- which is where those
two operations reach the
+ * shared base-table hook at all.
+ */
+ private void createPartitionedTableWithoutRowBinlog(String db) throws
Exception {
+ createDatabaseAndUse(db);
+ createTable("CREATE TABLE " + db + ".ivm_base (\n"
+ + " dt date NOT NULL,\n"
+ + " k1 int,\n"
+ + " v1 int\n"
+ + ")\n"
+ + "DUPLICATE KEY(dt, k1)\n"
+ + "PARTITION BY RANGE(dt) (\n"
+ + " PARTITION p202001 VALUES [('2020-01-01'),
('2020-02-01')),\n"
+ + " PARTITION p202002 VALUES [('2020-02-01'),
('2020-03-01'))\n"
+ + ")\n"
+ + "DISTRIBUTED BY HASH(k1) BUCKETS 1\n"
+ + "PROPERTIES ('replication_num' = '1')");
+ }
+
/**
* Gives every MV partition the entry a refresh would have created. A
refresh task aligns before it
* reads a base table, so an MV that has been refreshed has one entry per
partition; the marker tests
@@ -1214,6 +1474,17 @@ public class IvmBaselineRebuildTest extends
TestWithFeService {
"the detail must name the reason, was: " +
mtmv.getStatus().getSchemaChangeDetail());
}
+ /**
+ * The MV was left alone by a base-table change. An invalidation moves
three things -- the state, the
+ * version that discards a task result computed against it, and the
snapshot that stops the transparent
+ * rewrite -- so the state alone is only a third of the answer, and the
version is the one of the three
+ * that is observable after a change that names no snapshot.
+ */
+ private void assertNotInvalidated(MTMV mtmv, long versionBefore, String
message) {
+ Assertions.assertNotEquals(MTMVState.SCHEMA_CHANGE,
mtmv.getStatus().getState(), message);
+ Assertions.assertEquals(versionBefore, mtmv.getSchemaChangeVersion(),
message);
+ }
+
private Database getDb(String db) {
return Env.getCurrentInternalCatalog().getDb(db).get();
}
diff --git
a/regression-test/data/mtmv_p0/test_base_drop_col_multi_level_mtmv.out
b/regression-test/data/mtmv_p0/test_base_drop_col_multi_level_mtmv.out
index 2a3165773c1..979eeb3086b 100644
--- a/regression-test/data/mtmv_p0/test_base_drop_col_multi_level_mtmv.out
+++ b/regression-test/data/mtmv_p0/test_base_drop_col_multi_level_mtmv.out
@@ -6,7 +6,7 @@ test_base_drop_col_multi_level_mtmv_mv1 SCHEMA_CHANGE SUCCESS
false
test_base_drop_col_multi_level_mtmv_mv2 NORMAL SUCCESS true
-- !add_col_t1_mv3 --
-test_base_drop_col_multi_level_mtmv_mv3 SCHEMA_CHANGE SUCCESS false
+test_base_drop_col_multi_level_mtmv_mv3 NORMAL SUCCESS true
-- !add_col_t1_mv4 --
test_base_drop_col_multi_level_mtmv_mv4 NORMAL SUCCESS true
diff --git a/regression-test/data/mtmv_p0/test_base_mtmv.out
b/regression-test/data/mtmv_p0/test_base_mtmv.out
index c98bc3167cc..9e54a6372ca 100644
--- a/regression-test/data/mtmv_p0/test_base_mtmv.out
+++ b/regression-test/data/mtmv_p0/test_base_mtmv.out
@@ -9,7 +9,7 @@ multi_mv_test_base_mtmv NORMAL SUCCESS true
multi_mv_test_base_mtmv NORMAL SUCCESS true
-- !rename_column --
-multi_mv_test_base_mtmv SCHEMA_CHANGE SUCCESS false
+multi_mv_test_base_mtmv NORMAL SUCCESS true
-- !success --
multi_mv_test_base_mtmv NORMAL SUCCESS true
@@ -21,7 +21,7 @@ multi_mv_test_base_mtmv SCHEMA_CHANGE SUCCESS false
multi_mv_test_base_mtmv NORMAL SUCCESS true
-- !drop_column --
-multi_mv_test_base_mtmv SCHEMA_CHANGE SUCCESS false
+multi_mv_test_base_mtmv NORMAL SUCCESS true
-- !success --
multi_mv_test_base_mtmv NORMAL SUCCESS true
diff --git
a/regression-test/data/mtmv_p0/test_base_rename_col_multi_level_mtmv.out
b/regression-test/data/mtmv_p0/test_base_rename_col_multi_level_mtmv.out
index d2c39530900..4d64f78d5f6 100644
--- a/regression-test/data/mtmv_p0/test_base_rename_col_multi_level_mtmv.out
+++ b/regression-test/data/mtmv_p0/test_base_rename_col_multi_level_mtmv.out
@@ -6,7 +6,7 @@ test_base_rename_col_multi_level_mtmv_mv1 SCHEMA_CHANGE
SUCCESS false
test_base_rename_col_multi_level_mtmv_mv2 NORMAL SUCCESS true
-- !add_col_t1_mv3 --
-test_base_rename_col_multi_level_mtmv_mv3 SCHEMA_CHANGE SUCCESS false
+test_base_rename_col_multi_level_mtmv_mv3 NORMAL SUCCESS true
-- !add_col_t1_mv4 --
test_base_rename_col_multi_level_mtmv_mv4 NORMAL SUCCESS true
diff --git
a/regression-test/data/mtmv_p0/test_drop_unreferenced_column_mtmv.out
b/regression-test/data/mtmv_p0/test_drop_unreferenced_column_mtmv.out
new file mode 100644
index 00000000000..179b3f06936
--- /dev/null
+++ b/regression-test/data/mtmv_p0/test_drop_unreferenced_column_mtmv.out
@@ -0,0 +1,125 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !mow_baseline --
+1 100
+2 200
+
+-- !mow_state_after_unreferenced_drop --
+test_drop_unreferenced_column_mtmv_mow_mv NORMAL SUCCESS true
+
+-- !mow_state_after_referenced_drop --
+test_drop_unreferenced_column_mtmv_mow_mv SCHEMA_CHANGE SUCCESS false
+
+-- !mow_rows_after_both_drops --
+1 100
+2 200
+
+-- !dup_baseline --
+1 100
+2 200
+
+-- !dup_rows_after_unreferenced_drop --
+1 100
+2 200
+
+-- !scope_drop_baseline --
+1
+
+-- !scope_drop_state --
+test_drop_unreferenced_column_mtmv_scope_drop_mv SCHEMA_CHANGE SUCCESS
false
+
+-- !scope_add_baseline --
+1
+
+-- !scope_add_unreached --
+test_drop_unreferenced_column_mtmv_scope_add_mv NORMAL SUCCESS true
+
+-- !scope_add_state --
+test_drop_unreferenced_column_mtmv_scope_add_mv SCHEMA_CHANGE SUCCESS
false
+
+-- !scope_rename_baseline --
+1
+
+-- !scope_rename_state --
+test_drop_unreferenced_column_mtmv_scope_rename_mv SCHEMA_CHANGE SUCCESS
false
+
+-- !qualified_baseline --
+1
+
+-- !qualified_drop_state --
+test_drop_unreferenced_column_mtmv_qual_mv NORMAL SUCCESS true
+
+-- !qualified_add_state --
+test_drop_unreferenced_column_mtmv_qual_mv NORMAL SUCCESS true
+
+-- !qualified_rows --
+1
+
+-- !job_add_baseline --
+test_drop_unreferenced_column_mtmv_job_add_mv NORMAL SUCCESS true
+
+-- !job_add_cols_baseline --
+test_drop_unreferenced_column_mtmv_job_add_cols_mv NORMAL SUCCESS true
+
+-- !projection_baseline --
+1
+
+-- !projection_state --
+test_drop_unreferenced_column_mtmv_proj_mv SCHEMA_CHANGE SUCCESS false
+
+-- !alias_baseline --
+0
+
+-- !alias_state --
+test_drop_unreferenced_column_mtmv_alias_mv SCHEMA_CHANGE SUCCESS false
+
+-- !ignored_projection_state --
+test_drop_unreferenced_column_mtmv_ignored_mv NORMAL SUCCESS true
+
+-- !ignored_projection_rows --
+1
+
+-- !named_not_read_state --
+test_drop_unreferenced_column_mtmv_named_mv NORMAL SUCCESS true
+
+-- !sibling_baseline --
+1
+
+-- !sibling_state --
+test_drop_unreferenced_column_mtmv_sibling_mv NORMAL SUCCESS true
+
+-- !sibling_rows --
+1
+
+-- !nested_baseline --
+1
+
+-- !nested_state --
+test_drop_unreferenced_column_mtmv_nested_mv NORMAL SUCCESS true
+
+-- !nested_rows --
+1
+
+-- !qualify_baseline --
+0
+
+-- !qualify_state --
+test_drop_unreferenced_column_mtmv_qualify_mv SCHEMA_CHANGE SUCCESS false
+
+-- !alias_of_another_column_baseline --
+1
+
+-- !alias_of_another_column_state --
+test_drop_unreferenced_column_mtmv_alias_of_mv NORMAL SUCCESS true
+
+-- !alias_of_another_column_rows --
+1
+
+-- !alias_in_subquery_baseline --
+1
+
+-- !alias_in_subquery_state --
+test_drop_unreferenced_column_mtmv_alias_in_mv NORMAL SUCCESS true
+
+-- !alias_in_subquery_rows --
+1
+
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
index 1bb6cb99c26..c3df98913ae 100644
---
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
@@ -109,11 +109,11 @@ suite("test_ivm_drop_referenced_column_baseline_rebuild")
{
order_qt_mv_rows_baseline "SELECT grp, cnt, total FROM ${mvName}"
// ------------------------------------- 2. unreferenced column: no
baseline invalidation
- // The IVM baseline itself is untouched -- no partition requirement is
raised. The shared base-table
- // change hook still moves the MV into SCHEMA_CHANGE for a column change,
though, and that state is
- // what the refresh below reads: it is escalated to a whole-MV COMPLETE.
Narrowing the hook so a
- // change that re-analyses cleanly leaves an IVM MV alone is PR 4's S1-5;
pinned here so the
- // escalation cannot pass unnoticed until then.
+ // Neither half is touched: no partition requirement is raised, and the
shared base-table change hook
+ // leaves the MV alone as well -- its criterion is a re-analysis of the
MV's own query, and a column
+ // the query does not name leaves that query analysable, so there is no
rebuild for the state to stand
+ // for. How that shows up is what the refresh below reports: it stays on
the incremental path, which is
+ // the path that records no refresh mode at all, instead of being
escalated to a whole-MV COMPLETE.
def before = ddlJobCount(tableName)
sql """ALTER TABLE ${tableName} DROP COLUMN spare"""
waitDdlFinished(tableName, before)
@@ -121,11 +121,13 @@ suite("test_ivm_drop_referenced_column_baseline_rebuild")
{
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)
- assertEquals("COMPLETE", task.RefreshMode.toString(),
- "the shared hook still moves the MV into SCHEMA_CHANGE, so this
refresh is escalated")
- assertEquals("1", task.IvmRebuiltPartitions.toString(),
- "and the escalated refresh reports the partition it rebuilt
instead of the request it got")
+ "dropping an unreferenced column must not invalidate the MV: " +
task.ErrorMsg)
+ // Only the partition and whole-MV attempts set a mode; an incremental one
leaves the column unset,
+ // which comes back as the same literal the folding in the other suites
turns into a token.
+ assertEquals("\\N", task.RefreshMode.toString(),
+ "the refresh must not be escalated to a whole-MV COMPLETE, so it
records no refresh mode")
+ assertEquals("0", task.IvmRebuiltPartitions.toString(),
+ "and it rebuilds no partition the request did not ask for")
// --------------------------- 3. referenced column: the refresh can no
longer analyse
before = ddlJobCount(tableName)
diff --git
a/regression-test/suites/mtmv_p0/test_base_drop_col_multi_level_mtmv.groovy
b/regression-test/suites/mtmv_p0/test_base_drop_col_multi_level_mtmv.groovy
index 14cb8a2faa4..3a2f8c83dac 100644
--- a/regression-test/suites/mtmv_p0/test_base_drop_col_multi_level_mtmv.groovy
+++ b/regression-test/suites/mtmv_p0/test_base_drop_col_multi_level_mtmv.groovy
@@ -136,6 +136,8 @@ suite("test_base_drop_col_multi_level_mtmv","mtmv") {
mv_rewrite_success_without_check_chosen(querySql, mvName2)
mv_not_part_in(querySql, mvName1)
- mv_not_part_in(querySql, mvName3)
+ // mv3 is the query being asked, and the column this change took away is
not one it names: the MV it
+ // stands for is untouched, so it stays a candidate -- and it is the one
the plan picks.
+ mv_rewrite_success(querySql, mvName3)
mv_rewrite_fail(querySql, mvName4)
}
diff --git
a/regression-test/suites/mtmv_p0/test_base_rename_col_multi_level_mtmv.groovy
b/regression-test/suites/mtmv_p0/test_base_rename_col_multi_level_mtmv.groovy
index f1ad7a59b44..ee89542ef87 100644
---
a/regression-test/suites/mtmv_p0/test_base_rename_col_multi_level_mtmv.groovy
+++
b/regression-test/suites/mtmv_p0/test_base_rename_col_multi_level_mtmv.groovy
@@ -135,6 +135,8 @@ suite("test_base_rename_col_multi_level_mtmv","mtmv") {
mv_rewrite_success_without_check_chosen(querySql, mvName2)
mv_not_part_in(querySql, mvName1)
- mv_not_part_in(querySql, mvName3)
+ // mv3 is the query being asked, and the column this change took away is
not one it names: the MV it
+ // stands for is untouched, so it stays a candidate -- and it is the one
the plan picks.
+ mv_rewrite_success(querySql, mvName3)
mv_rewrite_fail(querySql, mvName4)
}
diff --git
a/regression-test/suites/mtmv_p0/test_drop_unreferenced_column_mtmv.groovy
b/regression-test/suites/mtmv_p0/test_drop_unreferenced_column_mtmv.groovy
new file mode 100644
index 00000000000..28a2afa9969
--- /dev/null
+++ b/regression-test/suites/mtmv_p0/test_drop_unreferenced_column_mtmv.groovy
@@ -0,0 +1,659 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import org.junit.Assert;
+
+/**
+ * Which base-table column changes invalidate a materialized view.
+ *
+ * <p>A dropped column is judged by re-analysing the MV's own query -- but
only where that question has an
+ * answer. The query is analysed against the table as it is when the alter
reaches the MV hook, and unless
+ * the change is a light one it has not been applied yet at that point: it was
submitted as a job, the
+ * column is still there, and every query still analyses. An invalidation
decided on that answer would be
+ * about the table from before the change, which is exactly where a dropped
and re-added column leaves the
+ * ABA the invalidation exists for. So a change that is not in place keeps
invalidating, and the two halves
+ * of this suite pin the two answers:
+ * <ol>
+ * <li>a merge-on-write table, where dropping a value column is a light
change: the MV whose query does
+ * not name the column is left alone, and the MV whose query names it is
invalidated;</li>
+ * <li>a duplicate table, where the same drop is not a light one and a job
does the data rewrite: the
+ * column is out of the table's schema before the hook runs all the
same, so the same answer holds.
+ * What the gate is for is the other side of that -- a change a job has
not applied yet, where the
+ * query would be analysed against the table from before it. There the
MV is invalidated, which is
+ * what every column change did before the queries were asked at
all.</li>
+ * </ol>
+ *
+ * <p>That second half is the integrated cluster's. Cloud has no table a job
applies a column change to:
+ * `light_schema_change` there is the platform's rather than the table's -- a
table created with it false
+ * reports and uses it true, and the change lands in the statement -- so on
cloud the change is judged like
+ * any light one and the answer is the first half's. Both answers are asserted
where they differ, and the
+ * reason is written where the shapes are.
+ *
+ * <p>The rest of the suite is about which column a name in the query answers
for, because that is what
+ * makes a change to a column nothing reads. A name is a column of the table
the change is about only where
+ * the query reads it from there, and it is the scopes' to answer for where
the query resolves it across a
+ * scope boundary: what is left is a name bound to another table inside the
query's own scope, which no
+ * later change can move.
+ *
+ * <p>The second half is also where the consequence is observable: the rewrite
reaches the MV on that table
+ * and not on a merge-on-write one, so the state the change records is what is
left to report on the first.
+ * The IVM side of the same change, where the state is what escalates the next
refresh to a whole-MV
+ * COMPLETE, is pinned in the ivm directory.
+ */
+suite("test_drop_unreferenced_column_mtmv") {
+ String dbName = context.config.getDbNameByFile(context.file)
+ String suiteName = "test_drop_unreferenced_column_mtmv"
+
+ // The state a view is left in, kept out of the goldens where the answer
itself is not the same on both
+ // kinds of cluster: a golden holds one answer, and the drop a job would
have applied (below) is not a
+ // drop cloud has.
+ def mvStateOf = { String name ->
+ (sql("select State from mv_infos('database'='${dbName}') where
Name='${name}'")[0][0]) as String
+ }
+
+ // ------------------------------------------------- 1. the change is in
place: the query decides
+ String mowTable = "${suiteName}_mow_table"
+ String mowMv = "${suiteName}_mow_mv"
+
+ sql """drop materialized view if exists ${mowMv}"""
+ sql """drop table if exists ${mowTable}"""
+ sql """
+ CREATE TABLE ${mowTable}
+ (
+ k1 INT NOT NULL,
+ amount BIGINT,
+ spare BIGINT
+ )
+ UNIQUE KEY(k1)
+ DISTRIBUTED BY HASH(k1) BUCKETS 2
+ PROPERTIES ("replication_num" = "1",
"enable_unique_key_merge_on_write" = "true")
+ """
+ sql """INSERT INTO ${mowTable} VALUES (1, 100, 7), (2, 200, 8)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${mowMv}
+ BUILD DEFERRED REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(k1) BUCKETS 2
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT k1, SUM(amount) AS total FROM ${mowTable} GROUP BY k1
+ """
+ sql """REFRESH MATERIALIZED VIEW ${mowMv} COMPLETE"""
+ waitingMTMVTaskFinishedByMvName(mowMv)
+ order_qt_mow_baseline "SELECT k1, total FROM ${mowMv}"
+
+ // A column the query does not name. Dropping it gives this MV nothing to
recompute, so it is not
+ // invalidated: it stays a refresh candidate, and the state is where that
shows.
+ sql """ALTER TABLE ${mowTable} DROP COLUMN spare"""
+ assertEquals("FINISHED", getAlterColumnFinalState("${mowTable}"))
+ order_qt_mow_state_after_unreferenced_drop "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${mowMv}'"
+
+ // A column the query names. The MV cannot be computed from the table any
more, so it is invalidated.
+ sql """ALTER TABLE ${mowTable} DROP COLUMN amount"""
+ assertEquals("FINISHED", getAlterColumnFinalState("${mowTable}"))
+ order_qt_mow_state_after_referenced_drop "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${mowMv}'"
+ // Neither column was one the rows the MV holds depended on: the
invalidation above is not about the
+ // data being wrong, it is about the query the data stands for.
+ order_qt_mow_rows_after_both_drops "SELECT k1, total FROM ${mowMv}"
+
+ // ------------------------- 2. the same drop on a table whose data a job
rewrites: same answer
+ String dupTable = "${suiteName}_dup_table"
+ String dupMv = "${suiteName}_dup_mv"
+ String dupQuery = "SELECT k1, SUM(amount) AS total FROM ${dupTable} GROUP
BY k1"
+
+ String scopeDropOuter = "${suiteName}_scope_drop_outer"
+ String scopeDropInner = "${suiteName}_scope_drop_inner"
+ String scopeDropMv = "${suiteName}_scope_drop_mv"
+ String scopeAddOuter = "${suiteName}_scope_add_outer"
+ String scopeAddInner = "${suiteName}_scope_add_inner"
+ String scopeAddMv = "${suiteName}_scope_add_mv"
+
+ sql """drop materialized view if exists ${dupMv}"""
+ sql """drop table if exists ${dupTable}"""
+ sql """
+ CREATE TABLE ${dupTable}
+ (
+ k1 INT,
+ amount BIGINT,
+ spare BIGINT
+ )
+ DUPLICATE KEY(k1)
+ DISTRIBUTED BY HASH(k1) BUCKETS 2
+ PROPERTIES ("replication_num" = "1", "light_schema_change" = "false")
+ """
+ sql """INSERT INTO ${dupTable} VALUES (1, 100, 7), (2, 200, 8)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${dupMv}
+ BUILD DEFERRED REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(k1) BUCKETS 2
+ PROPERTIES ("replication_num" = "1")
+ AS SELECT k1, SUM(amount) AS total FROM ${dupTable} GROUP BY k1
+ """
+ sql """REFRESH MATERIALIZED VIEW ${dupMv} COMPLETE"""
+ waitingMTMVTaskFinishedByMvName(dupMv)
+ order_qt_dup_baseline "SELECT k1, total FROM ${dupMv}"
+ // This MV is reachable by the rewrite, which is what makes the answer
below observable: the MV takes
+ // part in it before the change, and a change that invalidated the MV
would take it out.
+ mv_rewrite_success_without_check_chosen(dupQuery, dupMv)
+
+ // The other half of the answer, on a table where a job applies the change
rather than the statement.
+ // The hook runs where that job has not run yet, so the table still holds
the column and every query
+ // analyses against it: nothing can be concluded about the column from a
query, and the MV is
+ // invalidated the way it was before the queries were asked at all.
+ //
+ // Cloud has no such table. There `light_schema_change` is not the table's
to choose: a table created
+ // with it false reports it true and takes the column change in the
statement itself (a schema change
+ // there is a metadata change with no data rewrite behind it), so this
drop is the light one of the half
+ // above and gets that half's answer -- the change reached the table, the
query was asked, and the MV is
+ // left where it is. Both halves of the answer are pinned here: the state
by an assertion, because a
+ // golden can only hold one of the two.
+ sql """ALTER TABLE ${dupTable} DROP COLUMN spare"""
+ assertEquals("FINISHED", getAlterColumnFinalState("${dupTable}"))
+ assertEquals(isCloudMode() ? "NORMAL" : "SCHEMA_CHANGE", mvStateOf(dupMv))
+ if (isCloudMode()) {
+ // The change does not reach this MV, so it is still a candidate for
the rewrite.
+ mv_rewrite_success_without_check_chosen(dupQuery, dupMv)
+ } else {
+ mv_not_part_in(dupQuery, dupMv)
+ }
+ order_qt_dup_rows_after_unreferenced_drop "SELECT k1, total FROM ${dupMv}"
+
+ // ---- the name a query reaches a column by can move, and that is the
query's to judge ----
+ // `flag` below is unqualified inside the subquery, so it is the inner
table's column while that table
+ // has one: the column nearest to a name in the query's scopes answers for
it. Taking that column away
+ // leaves the name to the outer table and the query still analyses with
the columns it always produced,
+ // while its rows are the ones of the column that went away.
+ sql """drop materialized view if exists ${scopeDropMv}"""
+ sql """drop table if exists ${scopeDropOuter}"""
+ sql """drop table if exists ${scopeDropInner}"""
+ sql """
+ CREATE TABLE ${scopeDropOuter} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${scopeDropInner} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${scopeDropOuter} VALUES (1, 0)"""
+ sql """INSERT INTO ${scopeDropInner} VALUES (1, 1)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${scopeDropMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT o.id FROM ${scopeDropOuter} o
+ WHERE EXISTS (SELECT 1 FROM ${scopeDropInner} i WHERE i.id = o.id AND
flag = 1)
+ """
+ waitingMTMVTaskFinishedByMvName(scopeDropMv)
+ order_qt_scope_drop_baseline "SELECT id FROM ${scopeDropMv}"
+ sql """ALTER TABLE ${scopeDropInner} DROP COLUMN flag"""
+ order_qt_scope_drop_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${scopeDropMv}'"
+
+ // And the same name can be taken over by a column that arrives. The inner
table has no `flag` here, so
+ // the name is the outer table's to answer; a column added inside the
subquery's scope answers for it
+ // from then on. A column added that no query reaches a name by leaves
every MV alone.
+ sql """drop materialized view if exists ${scopeAddMv}"""
+ sql """drop table if exists ${scopeAddOuter}"""
+ sql """drop table if exists ${scopeAddInner}"""
+ sql """
+ CREATE TABLE ${scopeAddOuter} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${scopeAddInner} (id INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${scopeAddOuter} VALUES (1, 1)"""
+ sql """INSERT INTO ${scopeAddInner} VALUES (1)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${scopeAddMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT o.id FROM ${scopeAddOuter} o
+ WHERE EXISTS (SELECT 1 FROM ${scopeAddInner} i WHERE i.id = o.id AND
flag = 1)
+ """
+ waitingMTMVTaskFinishedByMvName(scopeAddMv)
+ order_qt_scope_add_baseline "SELECT id FROM ${scopeAddMv}"
+ sql """ALTER TABLE ${scopeAddInner} ADD COLUMN unrelated BIGINT"""
+ order_qt_scope_add_unreached "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${scopeAddMv}'"
+ sql """ALTER TABLE ${scopeAddInner} ADD COLUMN flag INT DEFAULT 0"""
+ order_qt_scope_add_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${scopeAddMv}'"
+
+ // A rename takes a name over the same way an add gives one, and it does
it with two names at once: the
+ // one the query spelled, which the column no longer answers for, and the
one that answers for it from
+ // then on. Neither of the two says on its own whether the query moved --
the old name is one no query
+ // reaches any more, and the new one is only a move where the old name was
the query's before -- so the
+ // rename reports both. Here the inner table has `x` and no `flag`, so
`flag` is the outer table's, and
+ // renaming `x` to `flag` hands the name to the inner scope: the query
goes on producing `o.id` out of
+ // the rows of a column it never read, and the MV is invalidated.
+ String scopeRenameOuter = "${suiteName}_scope_rename_outer"
+ String scopeRenameInner = "${suiteName}_scope_rename_inner"
+ String scopeRenameMv = "${suiteName}_scope_rename_mv"
+ sql """drop materialized view if exists ${scopeRenameMv}"""
+ sql """drop table if exists ${scopeRenameOuter}"""
+ sql """drop table if exists ${scopeRenameInner}"""
+ sql """
+ CREATE TABLE ${scopeRenameOuter} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${scopeRenameInner} (id INT, x INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${scopeRenameOuter} VALUES (1, 1)"""
+ sql """INSERT INTO ${scopeRenameInner} VALUES (1, 0)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${scopeRenameMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT o.id FROM ${scopeRenameOuter} o
+ WHERE EXISTS (SELECT 1 FROM ${scopeRenameInner} i WHERE i.id = o.id
AND flag = 1)
+ """
+ waitingMTMVTaskFinishedByMvName(scopeRenameMv)
+ order_qt_scope_rename_baseline "SELECT id FROM ${scopeRenameMv}"
+ sql """ALTER TABLE ${scopeRenameInner} RENAME COLUMN x flag"""
+ order_qt_scope_rename_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${scopeRenameMv}'"
+
+ // ---- a name another table answers for is not this one's to give or take
----
+ // `flag` here is written with the qualifier of the second table, so what
the query reads does not turn
+ // on anything the first table does with a `flag` of its own: a name bound
where it is written is one no
+ // later change can move. The first table's `flag` is 0 and the second's
is 1, so the row the MV holds is
+ // the one the qualified binding produces -- a query that had read the
first table's column would hold
+ // none -- and taking that column away, or giving it back, leaves this
query and this MV alone.
+ String qualOuter = "${suiteName}_qual_outer"
+ String qualInner = "${suiteName}_qual_inner"
+ String qualMv = "${suiteName}_qual_mv"
+
+ sql """drop materialized view if exists ${qualMv}"""
+ sql """drop table if exists ${qualOuter}"""
+ sql """drop table if exists ${qualInner}"""
+ sql """
+ CREATE TABLE ${qualOuter} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${qualInner} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${qualOuter} VALUES (1, 0)"""
+ sql """INSERT INTO ${qualInner} VALUES (1, 1)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${qualMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT a.id FROM ${qualOuter} a JOIN ${qualInner} b ON a.id = b.id
WHERE b.flag = 1
+ """
+ waitingMTMVTaskFinishedByMvName(qualMv)
+ order_qt_qualified_baseline "SELECT id FROM ${qualMv}"
+ sql """ALTER TABLE ${qualOuter} DROP COLUMN flag"""
+ order_qt_qualified_drop_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${qualMv}'"
+ sql """ALTER TABLE ${qualOuter} ADD COLUMN flag INT DEFAULT 0"""
+ order_qt_qualified_add_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${qualMv}'"
+ order_qt_qualified_rows "SELECT id FROM ${qualMv}"
+
+ // ---- an add a job applies: there is no query to ask ----
+ // The hook of a change that is not a light one runs before the job has
applied it, so the table is the one
+ // from before the change and every query is analysed against that. A
column that is not in the table yet
+ // is one no query can be looked at for, and the MV is invalidated -- for
a column added on its own and for
+ // a batch of them, which is how an add is written either way. What the
add would do once the job lands is
+ // not readable here, and a name it may answer for is one the rows of
these views were not computed under.
+ String jobAddTable = "${suiteName}_job_add_table"
+ String jobAddMv = "${suiteName}_job_add_mv"
+ String jobAddColsTable = "${suiteName}_job_add_cols_table"
+ String jobAddColsMv = "${suiteName}_job_add_cols_mv"
+ sql """drop materialized view if exists ${jobAddMv}"""
+ sql """drop materialized view if exists ${jobAddColsMv}"""
+ sql """drop table if exists ${jobAddTable}"""
+ sql """drop table if exists ${jobAddColsTable}"""
+ sql """
+ CREATE TABLE ${jobAddTable} (id INT, amount INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1", "light_schema_change" = "false")
+ """
+ sql """
+ CREATE TABLE ${jobAddColsTable} (id INT, amount INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1
+ PROPERTIES ("replication_num" = "1", "light_schema_change" = "false")
+ """
+ sql """INSERT INTO ${jobAddTable} VALUES (1, 100)"""
+ sql """INSERT INTO ${jobAddColsTable} VALUES (1, 100)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${jobAddMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT id, SUM(amount) AS total FROM ${jobAddTable} GROUP BY id
+ """
+ sql """
+ CREATE MATERIALIZED VIEW ${jobAddColsMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT id, SUM(amount) AS total FROM ${jobAddColsTable} GROUP BY id
+ """
+ waitingMTMVTaskFinishedByMvName(jobAddMv)
+ waitingMTMVTaskFinishedByMvName(jobAddColsMv)
+ order_qt_job_add_baseline "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${jobAddMv}'"
+ order_qt_job_add_cols_baseline "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${jobAddColsMv}'"
+ // On cloud the same add is a light one -- `light_schema_change` is not
the table's to choose there, as
+ // the drop above writes down -- so the column is in the table when the
hook runs and the query is asked:
+ // neither view reads it, and both are left where they are.
+ sql """ALTER TABLE ${jobAddTable} ADD COLUMN spare INT"""
+ assertEquals(isCloudMode() ? "NORMAL" : "SCHEMA_CHANGE",
mvStateOf(jobAddMv))
+ sql """ALTER TABLE ${jobAddColsTable} ADD COLUMN (spare INT, other INT)"""
+ assertEquals(isCloudMode() ? "NORMAL" : "SCHEMA_CHANGE",
mvStateOf(jobAddColsMv))
+
+ // ---- a name a subquery's own projection answers for ----
+ // The subquery below projects `flag` while the inner table has no column
of that name, so the name is
+ // the outer table's to answer for and the subquery projects the outer
column: the MV holds the row.
+ // Giving the inner table a column of that name hands the name to it, so
the projection reads the inner
+ // column from then on and the query returns no row -- while every column
the MV produces is the one it
+ // always produced. Nothing is read here that the rows of the view are
computed from, and the name is
+ // still the change's to answer for: a column of a subquery's own output
is where a name can move
+ // without moving an output of the view.
+ String projOuter = "${suiteName}_proj_outer"
+ String projInner = "${suiteName}_proj_inner"
+ String projMv = "${suiteName}_proj_mv"
+ sql """drop materialized view if exists ${projMv}"""
+ sql """drop table if exists ${projOuter}"""
+ sql """drop table if exists ${projInner}"""
+ sql """
+ CREATE TABLE ${projOuter} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${projInner} (x INT) DUPLICATE KEY(x)
+ DISTRIBUTED BY HASH(x) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${projOuter} VALUES (1, 1)"""
+ sql """INSERT INTO ${projInner} VALUES (1)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${projMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT o.id FROM ${projOuter} o WHERE o.flag IN (SELECT flag FROM
${projInner} i)
+ """
+ waitingMTMVTaskFinishedByMvName(projMv)
+ order_qt_projection_baseline "SELECT id FROM ${projMv}"
+ sql """ALTER TABLE ${projInner} ADD COLUMN flag INT DEFAULT 0"""
+ order_qt_projection_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${projMv}'"
+
+ // ---- a name a subquery names itself ----
+ // `flag` here is written unqualified inside the subquery and the subquery
names a `flag` of its own, so
+ // the name is the table's column while the table has one and the
subquery's own value once it does not:
+ // the group by and the having then read the constant, the subquery has a
row, and the query returns a
+ // row the view's rows do not have. Nothing of the view's is computed from
a column of the changed table
+ // afterwards, so the check has to read what the subquery itself names.
+ String aliasOuter = "${suiteName}_alias_outer"
+ String aliasInner = "${suiteName}_alias_inner"
+ String aliasMv = "${suiteName}_alias_mv"
+ sql """drop materialized view if exists ${aliasMv}"""
+ sql """drop table if exists ${aliasOuter}"""
+ sql """drop table if exists ${aliasInner}"""
+ sql """
+ CREATE TABLE ${aliasOuter} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ """
+ sql """
+ CREATE TABLE ${aliasInner} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ """
+ sql """INSERT INTO ${aliasOuter} VALUES (1, 1)"""
+ sql """INSERT INTO ${aliasInner} VALUES (1, 0)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${aliasMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ AS SELECT o.id FROM ${aliasOuter} o WHERE EXISTS (
+ SELECT 1 AS flag, COUNT(*) AS n FROM ${aliasInner} i
+ WHERE i.id = o.id GROUP BY flag HAVING flag = 1)
+ """
+ waitingMTMVTaskFinishedByMvName(aliasMv)
+ order_qt_alias_baseline "SELECT COUNT(*) FROM ${aliasMv}"
+ sql """ALTER TABLE ${aliasInner} DROP COLUMN flag"""
+ order_qt_alias_state "select Name,State,RefreshState,SyncWithBaseTables
from mv_infos('database'='${dbName}') where Name='${aliasMv}'"
+
+ // ---- a name a subquery projects and never compares ----
+ // The subquery below projects `spare`, which is only the outer table's
while the outer table is the one
+ // that has it; an EXISTS tests the rows of its subquery and not what it
projects, so the same rows come
+ // back after the inner table is given a column of that name. Reading the
subquery's scope has to leave
+ // that case alone, or a change this view does not depend on takes its
rewrite snapshot with it.
+ String ignoredOuter = "${suiteName}_ignored_outer"
+ String ignoredInner = "${suiteName}_ignored_inner"
+ String ignoredMv = "${suiteName}_ignored_mv"
+ sql """drop materialized view if exists ${ignoredMv}"""
+ sql """drop table if exists ${ignoredOuter}"""
+ sql """drop table if exists ${ignoredInner}"""
+ sql """
+ CREATE TABLE ${ignoredOuter} (id INT, spare INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ """
+ sql """
+ CREATE TABLE ${ignoredInner} (id INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ """
+ sql """INSERT INTO ${ignoredOuter} VALUES (1, 7)"""
+ sql """INSERT INTO ${ignoredInner} VALUES (1)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${ignoredMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ AS SELECT o.id FROM ${ignoredOuter} o
+ WHERE EXISTS (SELECT spare FROM ${ignoredInner} i WHERE i.id = o.id)
+ """
+ waitingMTMVTaskFinishedByMvName(ignoredMv)
+ sql """ALTER TABLE ${ignoredInner} ADD COLUMN spare INT DEFAULT 0"""
+ order_qt_ignored_projection_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${ignoredMv}'"
+ order_qt_ignored_projection_rows "SELECT id FROM ${ignoredMv}"
+
+ // ---- a name a subquery names and never reads ----
+ // The subquery below names a `flag` of its own and no reference in it
resolves to that name: the compared
+ // `flag` is the outer table's, written with its qualifier, and giving the
inner table a column of that
+ // name changes the subquery's own projection and nothing the query reads.
Naming a value is not reading
+ // it, so this MV is left where it is.
+ String namedOuter = "${suiteName}_named_outer"
+ String namedInner = "${suiteName}_named_inner"
+ String namedMv = "${suiteName}_named_mv"
+ sql """drop materialized view if exists ${namedMv}"""
+ sql """drop table if exists ${namedOuter}"""
+ sql """drop table if exists ${namedInner}"""
+ sql """
+ CREATE TABLE ${namedOuter} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ """
+ sql """
+ CREATE TABLE ${namedInner} (id INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ """
+ sql """INSERT INTO ${namedOuter} VALUES (1, 1)"""
+ sql """INSERT INTO ${namedInner} VALUES (1)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${namedMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES (\"replication_num\" =
\"1\")
+ AS SELECT o.id FROM ${namedOuter} o WHERE o.flag IN (SELECT 1 AS flag
FROM ${namedInner} i)
+ """
+ waitingMTMVTaskFinishedByMvName(namedMv)
+ sql """ALTER TABLE ${namedInner} ADD COLUMN flag INT DEFAULT 0"""
+ order_qt_named_not_read_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${namedMv}'"
+
+ // ---- a name a subquery names itself, in a scope the change reaches
nothing of ----
+ // The name below is the second subquery's own, and the table being
changed is one only the first
+ // subquery reads: nothing inside the second scope ever answered for the
changed column, so the name it
+ // answers for is not one the change moved, and the MV is left where it
is. Reading what a subquery
+ // names itself has to be limited to the scopes the changed table is one
of, or a scope elsewhere in the
+ // query that happens to name the same word is read as a dependency of
this view's rows.
+ String siblingOuter = "${suiteName}_sibling_outer"
+ String siblingOther = "${suiteName}_sibling_other"
+ String siblingChanged = "${suiteName}_sibling_changed"
+ String siblingMv = "${suiteName}_sibling_mv"
+ sql """drop materialized view if exists ${siblingMv}"""
+ sql """drop table if exists ${siblingOuter}"""
+ sql """drop table if exists ${siblingOther}"""
+ sql """drop table if exists ${siblingChanged}"""
+ sql """
+ CREATE TABLE ${siblingOuter} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${siblingOther} (u INT) DUPLICATE KEY(u)
+ DISTRIBUTED BY HASH(u) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${siblingChanged} (id INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${siblingOuter} VALUES (1, 1)"""
+ sql """INSERT INTO ${siblingOther} VALUES (1)"""
+ sql """INSERT INTO ${siblingChanged} VALUES (1)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${siblingMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT o.id FROM ${siblingOuter} o
+ WHERE EXISTS (SELECT 1 FROM ${siblingChanged} c WHERE c.id = o.id)
+ AND EXISTS (SELECT 1 AS flag, COUNT(*) AS n FROM ${siblingOther} u
GROUP BY flag HAVING flag = 1)
+ """
+ waitingMTMVTaskFinishedByMvName(siblingMv)
+ order_qt_sibling_baseline "SELECT id FROM ${siblingMv}"
+ sql """ALTER TABLE ${siblingChanged} ADD COLUMN flag INT DEFAULT 0"""
+ order_qt_sibling_state "select Name,State,RefreshState,SyncWithBaseTables
from mv_infos('database'='${dbName}') where Name='${siblingMv}'"
+ order_qt_sibling_rows "SELECT id FROM ${siblingMv}"
+
+ // ---- a projection of a subquery that the subquery around it never reads
----
+ // The IN compares the rows of its own subquery; the EXISTS inside that
subquery is asked only whether
+ // it has a row, never what it projects. So the `spare` the inner subquery
projects is the inner table's
+ // column only after the add, and either way the rows of this query are
the same -- the scope of the
+ // inner EXISTS is not read by the IN around it, and is judged with the
EXISTS it belongs to.
+ String nestedOuter = "${suiteName}_nested_outer"
+ String nestedMid = "${suiteName}_nested_mid"
+ String nestedInner = "${suiteName}_nested_inner"
+ String nestedMv = "${suiteName}_nested_mv"
+ sql """drop materialized view if exists ${nestedMv}"""
+ sql """drop table if exists ${nestedOuter}"""
+ sql """drop table if exists ${nestedMid}"""
+ sql """drop table if exists ${nestedInner}"""
+ sql """
+ CREATE TABLE ${nestedOuter} (id INT, k INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${nestedMid} (k INT, id INT, spare INT) DUPLICATE KEY(k)
+ DISTRIBUTED BY HASH(k) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${nestedInner} (id INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${nestedOuter} VALUES (1, 1)"""
+ sql """INSERT INTO ${nestedMid} VALUES (1, 1, 7)"""
+ sql """INSERT INTO ${nestedInner} VALUES (1)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${nestedMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT o.id FROM ${nestedOuter} o
+ WHERE o.k IN (SELECT m.k FROM ${nestedMid} m
+ WHERE EXISTS (SELECT spare FROM ${nestedInner} i WHERE i.id =
m.id))
+ """
+ waitingMTMVTaskFinishedByMvName(nestedMv)
+ order_qt_nested_baseline "SELECT id FROM ${nestedMv}"
+ sql """ALTER TABLE ${nestedInner} ADD COLUMN spare INT DEFAULT 0"""
+ order_qt_nested_state "select Name,State,RefreshState,SyncWithBaseTables
from mv_infos('database'='${dbName}') where Name='${nestedMv}'"
+ order_qt_nested_rows "SELECT id FROM ${nestedMv}"
+
+ // ---- a name written where the definition does not say which column it
was ----
+ // The judgement re-analyses the view's own definition, so a name in it
that the definition never bound
+ // to a column is one the re-analysis can bind elsewhere: `QUALIFY` below
reads the base table's `flag`
+ // while it is there, and the alias once it is not, which changes the rows
the query returns without
+ // changing the columns the view produces. A view's definition has to
carry the column each of its names
+ // was bound to, wherever that name is written, or a change to that column
is one this cannot see.
+ String qualifyTable = "${suiteName}_qualify_table"
+ String qualifyMv = "${suiteName}_qualify_mv"
+ sql """drop materialized view if exists ${qualifyMv}"""
+ sql """drop table if exists ${qualifyTable}"""
+ sql """
+ CREATE TABLE ${qualifyTable} (id INT, flag INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${qualifyTable} VALUES (1, 0)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${qualifyMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(flag) BUCKETS 1 PROPERTIES ("replication_num" =
"1")
+ AS SELECT 1 AS flag, ROW_NUMBER() OVER (ORDER BY id) AS rn FROM
${qualifyTable}
+ QUALIFY flag = 1 AND rn = 1
+ """
+ waitingMTMVTaskFinishedByMvName(qualifyMv)
+ order_qt_qualify_baseline "SELECT COUNT(*) FROM ${qualifyMv}"
+ sql """ALTER TABLE ${qualifyTable} DROP COLUMN flag"""
+ order_qt_qualify_state "select Name,State,RefreshState,SyncWithBaseTables
from mv_infos('database'='${dbName}') where Name='${qualifyMv}'"
+
+ // ---- a name an alias gave to a column of another name ----
+ // The projection below is called `flag` and reads the column `x`: an
alias renames what a slot is known
+ // by without moving the column it comes from, so a change to a column
named `flag` is a change to
+ // something this view's rows are not computed from -- the column it reads
keeps its own name. Holding
+ // the change against the name the slot is called invalidated the view for
the name it answers to
+ // rather than the column it reads.
+ String aliasOfAColumnTable = "${suiteName}_alias_of_table"
+ String aliasOfAColumnMv = "${suiteName}_alias_of_mv"
+ sql """drop materialized view if exists ${aliasOfAColumnMv}"""
+ sql """drop table if exists ${aliasOfAColumnTable}"""
+ sql """
+ CREATE TABLE ${aliasOfAColumnTable} (id INT, x INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${aliasOfAColumnTable} VALUES (1, 5)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${aliasOfAColumnMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT d.id FROM (SELECT c.id, c.x AS flag FROM
${aliasOfAColumnTable} c) d WHERE d.flag = 5
+ """
+ waitingMTMVTaskFinishedByMvName(aliasOfAColumnMv)
+ order_qt_alias_of_another_column_baseline "SELECT id FROM
${aliasOfAColumnMv}"
+ sql """ALTER TABLE ${aliasOfAColumnTable} ADD COLUMN flag INT DEFAULT 0"""
+ order_qt_alias_of_another_column_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${aliasOfAColumnMv}'"
+ order_qt_alias_of_another_column_rows "SELECT id FROM ${aliasOfAColumnMv}"
+
+ // ---- the same, read from the subquery a comparison takes its rows from
----
+ // `d.flag` below is the alias over `c.x` again, read by the IN rather
than by a filter. The same
+ // question is asked of it -- which column does this name read -- and the
same name is not the change's.
+ String aliasInTable = "${suiteName}_alias_in_table"
+ String aliasInOuter = "${suiteName}_alias_in_outer"
+ String aliasInMv = "${suiteName}_alias_in_mv"
+ sql """drop materialized view if exists ${aliasInMv}"""
+ sql """drop table if exists ${aliasInTable}"""
+ sql """drop table if exists ${aliasInOuter}"""
+ sql """
+ CREATE TABLE ${aliasInTable} (id INT, x INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """
+ CREATE TABLE ${aliasInOuter} (id INT, k INT) DUPLICATE KEY(id)
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ """
+ sql """INSERT INTO ${aliasInTable} VALUES (1, 5)"""
+ sql """INSERT INTO ${aliasInOuter} VALUES (1, 5)"""
+ sql """
+ CREATE MATERIALIZED VIEW ${aliasInMv}
+ BUILD IMMEDIATE REFRESH COMPLETE ON MANUAL
+ DISTRIBUTED BY HASH(id) BUCKETS 1 PROPERTIES ("replication_num" = "1")
+ AS SELECT o.id FROM ${aliasInOuter} o
+ WHERE o.k IN (SELECT d.flag FROM (SELECT c.x AS flag FROM
${aliasInTable} c) d)
+ """
+ waitingMTMVTaskFinishedByMvName(aliasInMv)
+ order_qt_alias_in_subquery_baseline "SELECT id FROM ${aliasInMv}"
+ sql """ALTER TABLE ${aliasInTable} ADD COLUMN flag INT DEFAULT 0"""
+ order_qt_alias_in_subquery_state "select
Name,State,RefreshState,SyncWithBaseTables from
mv_infos('database'='${dbName}') where Name='${aliasInMv}'"
+ order_qt_alias_in_subquery_rows "SELECT id FROM ${aliasInMv}"
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]