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


##########
fe/fe-core/src/main/java/org/apache/doris/catalog/MTMV.java:
##########
@@ -682,33 +687,217 @@ public void invalidateIvmBaseline() {
         editLogItem.await();
     }
 
-    public void invalidateIvmBaseline(BaseTableInfo baseTableInfo, Map<String, 
Long> changedPartitions) {
+    /**
+     * Mark the MV partitions that may hold rows read from the changed base 
table partitions as needing a
+     * rebuild. When those partitions cannot be determined, the whole MV is 
marked instead.
+     */
+    /**
+     * @return whether a barrier was recorded. The caller reports the two 
outcomes differently: a change
+     *         that no MV partition reads leaves nothing to rebuild and must 
not be logged as one.
+     */
+    public boolean invalidateIvmBaseline(BaseTableInfo baseTableInfo, 
Map<String, Long> changedPartitions) {
+        // Computed before the MV lock is taken, not inside it: the mapping 
reads the partition items of the
+        // MV and of every PCT table, so it takes those tables' locks, and the 
MV lock has to stay a leaf
+        // (nothing may be acquired under it) the way the rest of this class 
assumes. The selection does not
+        // need to be atomic with the barrier it produces: the barrier is 
recorded under the lock below, and
+        // the names it carries are intersected with the live partition names 
when they are consumed
+        // (MTMVTask).
+        Optional<Set<String>> affectedMvPartitions = 
selectAffectedMvPartitions(baseTableInfo,
+                changedPartitions);
+        if (affectedMvPartitions.isPresent() && 
affectedMvPartitions.get().isEmpty()) {
+            // No MV partition reads any of the changed base partitions, so 
this change cannot leave
+            // anything behind here: there is no barrier to persist, and 
skipping the version bump
+            // keeps it from discarding the result of a task that is already 
running.
+            LOG.debug("No MV partition is affected by changed base partitions, 
mv={}, baseTable={}, "
+                    + "changedPartitions={}", name, baseTableInfo, 
changedPartitions);
+            return false;
+        }
         EditLogItem editLogItem;
         writeMvLock();
         try {
             if (ivmInfo == null) {
                 ivmInfo = new IvmInfo();
             }
-            if (mvPartitionInfo.getPartitionType() != 
MTMVPartitionType.SELF_MANAGE
-                    && mvPartitionInfo.getPctInfos().stream()
-                    .anyMatch(pctInfo -> 
pctInfo.getTableInfo().equals(baseTableInfo))) {
-                Optional<Set<String>> mvPartitionNames = 
refreshSnapshot.getMvPartitionNames(baseTableInfo,
-                        changedPartitions);
-                if (mvPartitionNames.isPresent()) {
-                    
ivmInfo.addPendingBaselineRebuildPartitions(mvPartitionNames.get());
-                } else {
-                    // Without a snapshot for every changed base partition, a 
PARTITIONS rebuild is unsafe.
-                    ivmInfo.requireCompleteBaselineRebuild();
-                }
-            } else {
+            if (!affectedMvPartitions.isPresent()) {
+                // A narrower rebuild could leave a partition holding rows of 
the changed base partition
+                // untouched, and those rows cannot be repaired later: the 
change emitted no row binlog.
                 ivmInfo.requireCompleteBaselineRebuild();
+            } else {
+                
ivmInfo.addPendingBaselineRebuildPartitions(affectedMvPartitions.get());
             }
             schemaChangeVersion++;
             editLogItem = submitIvmInfoChange();
         } finally {
             writeMvUnlock();
         }
         editLogItem.await();
+        return true;
+    }
+
+    /**
+     * Select the MV partitions that may hold rows read from the changed base 
table partitions.
+     *
+     * <p>This asks which MV partitions read the changed base partitions at 
all, instead of (as the
+     * refresh snapshot based selection did) which of them had already seen 
them. The snapshot is a lower
+     * bound that is allowed to lag: a base partition that was added after the 
snapshot was captured never
+     * appears in it, so it can report "this partition never read the changed 
base partition" about a
+     * partition that does hold its rows. Missing a partition here is not 
repaired by a later refresh --
+     * dropping or truncating a base partition emits no row binlog, so the 
incremental path never learns
+     * about those orphan rows and they stay in the MV forever.
+     *
+     * <p>Three cases have no answer in the mapping, and each of them must 
rebuild the whole MV instead:
+     * a SELF_MANAGE MV (the mapping API answers nothing for it, although its 
single partition reads every
+     * base partition); a base table that is not one of the MV's PCT tables 
(the mapping is seeded from
+     * {@code getPctTables()} and never gains a table later, so a joined 
partition table that the MV's
+     * partition column does not reach is not described at all); and a changed 
partition that is not in
+     * the base table's metadata right now, which is how RECOVER PARTITION 
arrives here -- it marks before
+     * the partition is added back, so at this point the partition is still in 
the recycle bin.
+     *
+     * <p>Locking is the fourth way to end up rebuilding everything, but it is 
contention rather than a
+     * property of the MV: the tables whose partition items the mapping reads 
are locked with a bounded
+     * tryLock, and the MV is rebuilt only while one of them is being written. 
See the comment at that
+     * loop.
+     *
+     * <p>An empty result is meaningful, on the other hand: the mapping lists 
every base partition read by
+     * the MV, so a changed base partition that no MV partition maps to is 
read by none of them.
+     *
+     * @param changedBasePartitions base partition name to partition id, never 
empty
+     * @return {@link Optional#empty()} when the affected MV partitions cannot 
be determined, otherwise the
+     *         (possibly empty) set of MV partition names that must be rebuilt
+     */
+    private Optional<Set<String>> selectAffectedMvPartitions(BaseTableInfo 
baseTableInfo,
+            Map<String, Long> changedBasePartitions) {
+        if (mvPartitionInfo.getPartitionType() == 
MTMVPartitionType.SELF_MANAGE) {
+            return Optional.empty();
+        }
+        MTMVRelatedTableIf pctTable = findPctTable(baseTableInfo);
+        if (pctTable == null) {
+            return Optional.empty();
+        }
+        // Computing the mapping reads the partition items of the MV and of 
every PCT table, which means
+        // taking their read locks. The caller already holds the changed 
table's write lock (a partition DDL
+        // marks before it releases it), so these other reads must not block: 
two partition DDLs on two PCT
+        // tables of this MV would otherwise each hold the write lock the 
other one needs, and acquiring in
+        // id order cannot break a cycle whose first lock is already held. 
They are taken with a bounded
+        // tryLock instead, the way the stream cleanup treats a busy table: a 
busy table means a writer is
+        // involved, and then the whole MV is rebuilt. The list is still 
sorted by id so that the acquisition
+        // order matches the rest of the code base.
+        List<TableIf> tablesToRead = 
Lists.newArrayListWithCapacity(mvPartitionInfo.getPctInfos().size() + 1);
+        tablesToRead.add(this);
+        for (BaseColInfo pctInfo : mvPartitionInfo.getPctInfos()) {
+            if (pctInfo.getTableInfo().equals(baseTableInfo)) {
+                continue;
+            }
+            try {
+                tablesToRead.add(MTMVUtil.getTable(pctInfo.getTableInfo()));
+            } catch (Exception e) {
+                LOG.warn("Failed to resolve PCT table {}, rebuild the whole 
MV. mv={}",
+                        pctInfo.getTableInfo(), name, e);
+                return Optional.empty();
+            }
+        }
+        tablesToRead.sort(Comparator.comparing(TableIf::getId));
+        if (!MetaLockUtils.tryReadLockTables(tablesToRead, 
Table.TRY_LOCK_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
+            LOG.warn("A PCT table is busy, rebuild the whole MV {} instead of 
selecting part of it", name);
+            return Optional.empty();
+        }
+        try {
+            // A partition that is missing from the metadata here is invisible 
to the mapping as well, so
+            // an empty answer below would be indistinguishable from "no MV 
partition reads it". The match
+            // is deliberately exact: the mapping is keyed by the metadata's 
spelling, so a name that only
+            // differs in case must take the whole-MV path too, or the lookup 
below would quietly select
+            // nothing for a partition that some MV partition does read. Base 
tables that do not implement
+            // getPartitionNames -- the external ones -- report no partition 
at all, so a partition change
+            // on them always rebuilds the whole MV. That matches what the 
refresh-snapshot selection
+            // answered for them, and the mapping has never been exercised for 
external tables (IVM does
+            // not support them as base tables yet): revisit before taking the 
narrow path for them.
+            if 
(!pctTable.getPartitionNames().containsAll(changedBasePartitions.keySet())) {

Review Comment:
   Fixed in 57a1b3d71dc.
   
   You are right, and the id is the half of the identity the check was missing. 
`CatalogRecycleBin.recoverPartition` passes 
`Collections.singletonMap(partitionName, recoverPartition.getId())` while the 
recycled partition is still in the bin, and -- with an alias -- before 
`table.addPartition`, so the name it reports can belong to a replacement that 
is live at that moment. Membership by name accepted it, the mapping then 
described the replacement's range, and the recovered range was left uncovered: 
the MV partition that partition sync adds for it is read from a base partition 
no mapping entry names, and recovery emits no row binlog to fill it.
   
   The check now requires the live partition to be the one the caller described:
   
   ```java
   Partition livePartition = 
pctTable.getPartition(changedBasePartition.getKey());
   if (livePartition == null || livePartition.getId() != 
changedBasePartition.getValue()) {
       return Optional.empty();
   }
   ```
   
   `OlapTable` resolves names through `nameToPartition`, a naturally-ordered 
`TreeMap`, so this stays an exact-name lookup and the case-sensitivity the 
previous comment described is unchanged; `TableIf.getPartition` answers null by 
default, so external base tables keep taking the whole-MV path. The other three 
call sites still match, which is why the check lands on RECOVER and nowhere 
else: DROP and strict REPLACE pass `olapTable.getPartition(name).getId()` of 
the live partition, and TRUNCATE passes `origPartitions`, built from the live 
partitions before the replace.
   
   Coverage: 
`IvmBaselineRebuildTest.testRecoveredPartitionWhoseNameWasReusedRebuildsTheWholeMv`
 -- DROP p202001, ADD a different-range p202001, then `RECOVER PARTITION 
p202001 AS p202003`. On the previous head it fails with `expected: <true> but 
was: <false>`, that is, no barrier at all. 
`mtmv_p0/ivm/test_ivm_baseline_marker_scope` still narrows a change no MV 
partition reads to nothing (`unread_partition_task` SUCCESS rather than 
COMPLETE), so the added check did not turn the narrow path into a blanket 
rebuild.
   



##########
fe/fe-core/src/test/java/org/apache/doris/mtmv/ivm/IvmBaselineRebuildTest.java:
##########
@@ -122,35 +127,267 @@ public void 
testDropColumnMarksBaselineRebuildOnlyWhenReferenced() throws Except
         Assertions.assertTrue(mtmv.getIvmInfo().isBaselineRebuildRequired());
     }
 
+    /**
+     * Which MV partitions must be rebuilt is decided by the MV's partition 
mapping, not by what the
+     * refresh snapshot happens to record. This test publishes no snapshot at 
all: an MV whose partitions
+     * follow the base table's still narrows the rebuild down to the 
partitions that read the dropped one.
+     */
     @Test
-    public void testPublishedPctPartitionUsesPartitionsBaselineRebuild() 
throws Exception {
+    public void testDropPartitionMarksOnlyMvPartitionsThatReadIt() throws 
Exception {
         String db = "ivm_partitions_baseline_rebuild";
-        createPartitionedIvmTableAndMv(db);
+        createPartitionedIvmTableAndPartitionedMv(db);
         MTMV mtmv = getMtmv(db);
-        OlapTable baseTable = getBaseTable(db);
-        publishPctPartitionSnapshot(mtmv, baseTable, "p202001");
+        Assertions.assertEquals(2, mtmv.getPartitionNames().size());
+        Set<String> expected = mvPartitionsWithSameRange(mtmv, 
getBaseTable(db), "p202001");
+        Assertions.assertEquals(1, expected.size());
 
         executeSql("ALTER TABLE ivm_base DROP PARTITION p202001");
 
         
Assertions.assertFalse(mtmv.getIvmInfo().requiresCompleteBaselineRebuild());
-        Assertions.assertEquals(Collections.singleton("mv_partition"),
-                mtmv.getIvmInfo().getPendingBaselineRebuildPartitions());
+        Assertions.assertEquals(expected, 
mtmv.getIvmInfo().getPendingBaselineRebuildPartitions());
     }
 
+    /**
+     * A base partition that no MV partition reads: dropping it cannot leave 
any of its rows in the MV, so
+     * there is nothing to rebuild. The previous selection could not tell this 
apart from "the snapshot does
+     * not know this partition" and rebuilt the whole MV instead.
+     */
     @Test
-    public void testMissingPctSnapshotRequiresCompleteBaselineRebuild() throws 
Exception {
-        String db = "ivm_complete_baseline_rebuild";
-        createPartitionedIvmTableAndMv(db);
+    public void testDropPartitionOutsideMvPartitionsMarksNothing() throws 
Exception {
+        String db = "ivm_partition_outside_mv";
+        createPartitionedIvmTableAndPartitionedMv(db);
+        MTMV mtmv = getMtmv(db);
+        // Added after the MV was created and never synced into it, so no MV 
partition reads it.
+        executeSql("ALTER TABLE ivm_base ADD PARTITION p202003 "
+                + "VALUES [('2020-03-01'), ('2020-04-01'))");
+        Assertions.assertTrue(mvPartitionsWithSameRange(mtmv, 
getBaseTable(db), "p202003").isEmpty());
+
+        executeSql("ALTER TABLE ivm_base DROP PARTITION p202003");
+
+        Assertions.assertFalse(mtmv.getIvmInfo().isBaselineRebuildRequired());
+    }
+
+    /**
+     * A base partition the partition_sync_limit window no longer covers can 
still have its rows in an MV
+     * partition: the MV was built while that partition was inside the window, 
shrinking the window does not
+     * touch the MV's own partitions, and widening it again makes partition 
sync keep the partition holding
+     * those rows. TRUNCATE emits no binlog, so nothing incremental can repair 
them -- the whole MV has to be
+     * rebuilt rather than a partition being guessed at.
+     */
+    @Test
+    public void testChangedPartitionOutsideTheSyncWindowRebuildsTheWholeMv() 
throws Exception {
+        String db = "ivm_baseline_sync_window";
+        String thisYear = LocalDate.now().withDayOfYear(1).toString();
+        String nextYear = 
LocalDate.now().withDayOfYear(1).plusYears(1).toString();
+        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"
+                + "  PARTITION pThisYear VALUES [('" + thisYear + "'), ('" + 
nextYear + "'))\n"
+                + ")\n"
+                + "DISTRIBUTED BY HASH(k1) BUCKETS 1\n"
+                + "PROPERTIES ('replication_num' = '1', 'binlog.enable' = 
'true', 'binlog.format' = 'ROW')");
+        createMvByNereids("CREATE MATERIALIZED VIEW ivm_mv\n"
+                + "BUILD DEFERRED REFRESH INCREMENTAL ON MANUAL\n"
+                + "PARTITION BY(dt)\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.assertEquals(3, mtmv.getPartitionNames().size());
+
+        // The window now keeps only this year's partition, so p202001 leaves 
the mapping while the MV's own
+        // partition for it stays. TRUNCATE leaves the base partition in 
place, so partition sync would keep
+        // that MV partition too -- the rows it still holds are exactly what 
the rebuild has to remove.
+        executeSql("ALTER MATERIALIZED VIEW ivm_mv SET ('partition_sync_limit' 
= '1',"
+                + " 'partition_sync_time_unit' = 'YEAR')");
+        executeSql("TRUNCATE TABLE ivm_base PARTITION(p202001)");
+
+        
Assertions.assertTrue(mtmv.getIvmInfo().requiresCompleteBaselineRebuild());
+    }
+
+    /**
+     * The same window, with a change that touches a partition inside it and 
one outside it at once: the
+     * partition inside fills the selection, and the one outside contributes 
nothing because the window
+     * left it out of the mapping. Judging the change by "was anything 
selected" would mark only the MV
+     * partition backed by the inside half, and the rows of the outside half 
-- which the MV partition for
+     * it still holds -- would never be rebuilt.
+     */
+    @Test
+    public void 
testChangeThatMixesInWindowAndOutOfWindowPartitionsRebuildsTheWholeMv() throws 
Exception {
+        String db = "ivm_baseline_sync_window_mixed";
+        String thisYear = LocalDate.now().withDayOfYear(1).toString();

Review Comment:
   Fixed in 57a1b3d71dc, in both sync-window tests.
   
   The mechanism is as you describe: 
`RangePartitionItem.isGreaterThanSpecifiedTime` compares the partition's 
**upper** bound against `now()` truncated to the year, read when the marker 
runs, so `pThisYear = [this year's Jan 1, next year's Jan 1)` sits exactly on 
the cutoff as soon as the year turns between building the DDL and the TRUNCATE. 
Both partitions are then filtered out, the mapping describes nothing, and the 
test still asserts COMPLETE -- which is what the old `res.isEmpty()` 
implementation produced too, so it would stop exercising the non-empty 
incomplete selection it was added for.
   
   The recent partition now ends two years out, which no rollover inside the 
test's lifetime can reach:
   
   ```java
   String recentEnd = LocalDate.now().withDayOfYear(1).plusYears(2).toString();
   ```
   
   I did not take the second suggestion (asserting the mapping shape before the 
TRUNCATE): keeping the window meaningful is what the test is about, and pinning 
the mapping as well would restate what the COMPLETE assertion already covers 
once the range is rollover-proof.
   



-- 
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