yujun777 commented on code in PR #68646:
URL: https://github.com/apache/doris/pull/68646#discussion_r4142885262


##########
fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java:
##########
@@ -349,34 +357,140 @@ public void dropTable(Table table) {
         // 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);

Review Comment:
   Fixed in 9ba3062a3c6. Measured first: with `inner_t.flag = 0` the MV is 
empty and the query returns no row; after the light `DROP COLUMN flag` the 
query returns `o.id` while the MV stays NORMAL holding nothing, which is the 
row the rewrite answers with. With the change the MV is invalidated.
   
   The analysed plan says where the name went: the subquery's having becomes `1 
= 1` and its output is `1 AS flag`, so the name is answered for by the subquery 
itself rather than by any column. The scope a subquery became is therefore read 
for the values it names itself under one of these names, in addition to the 
columns of the changed table it reads -- which is the distinction between this 
and the existing thread: there the fallback is an outer column, here it is a 
value the subquery computes.
   
   Regression case `alias_*` in the drop suite, with the pre-drop state pinned 
as the empty MV, and it fails when that half of the read is removed.
   



##########
fe/fe-core/src/main/java/org/apache/doris/mtmv/MTMVRelationManager.java:
##########
@@ -349,34 +357,140 @@ public void dropTable(Table table) {
         // 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 is answered for inside a subquery, out of that 
subquery's own output.
+     *
+     * <p>This is the one place a name can move without any column the view 
produces depending on it: the
+     * projection of a subquery is internal, so what the name resolves to 
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.
+     */
+    private static boolean reachesAnyColumnOfASubquery(Plan plan, Set<String> 
names,
+            BaseTableInfo baseTableInfo) {
+        for (LogicalApply<?, ?> apply : 
plan.<LogicalApply>collectToList(LogicalApply.class::isInstance)) {
+            if (apply.right().anyMatch(node -> node instanceof Plan

Review Comment:
   Fixed in 9ba3062a3c6, along the line you draw: the subquery's scope is now 
read in two parts.
   
   A column of the changed table is held against it only when the subquery's 
output is one the query reads, which an EXISTS is not -- an EXISTS tests the 
rows of its subquery, not what it projects, so your shape is left NORMAL. 
Measured: after `ADD COLUMN spare` on the inner table the MV stays NORMAL and 
holds the same row the query returns, where before the change it was 
invalidated for a name nothing compares.
   
   A value the subquery names itself is read whatever the subquery is, because 
that one decides the rows even under an EXISTS: the group by and the having of 
a subquery are where it shows, which is the thread on the same commit about `1 
AS flag`.
   
   Two cases in the drop suite pin both sides (`ignored_projection_*` and 
`alias_*`), each failing with its half removed.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to