This is an automated email from the ASF dual-hosted git repository.

englefly 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 14ac5304b8e [fix](statistics) Create the table stats record when a 
load finishes (#69028)
14ac5304b8e is described below

commit 14ac5304b8eaeefd2b69786d73605ec865d44d49
Author: minghong <[email protected]>
AuthorDate: Wed Oct 28 16:41:00 2026 +0800

    [fix](statistics) Create the table stats record when a load finishes 
(#69028)
    
    An alternative implementation of the fix discussed in #68282, with a
    much smaller change: instead of keeping
    and resetting the statistics record across `TRUNCATE TABLE` (which also
    had to order every record transition
    with its journal entry), the record is recreated by the load itself, so
    a truncation keeps removing it exactly
    as it does today.
    
    ### What problem does this PR solve?
    
    Issue Number: None (no upstream issue filed; the case is described
    below)
    
    Problem Summary:
    
    Reproduction on a single bucket duplicate key table:
    
    CREATE TABLE t (k INT, v INT) DUPLICATE KEY(k) DISTRIBUTED BY HASH(k)
    BUCKETS 1;
        INSERT INTO t VALUES (1,1),(2,2),(3,3),(4,4),(5,5);
        TRUNCATE TABLE t;
        INSERT INTO t VALUES (1,1),(2,2),(3,3);
    EXPLAIN SELECT * FROM t; -- reports cardinality=1, although the table
    holds 3 rows
    
    TRUNCATE TABLE replaces the partitions and tablets of the table with new
    ones and removes its statistics
    record. The backends report the row count of the new tablets only after
    the delay of
    `tablet_stat_update_interval_second` (60s by default), and a partition
    reports no row count at all until its
    first load becomes visible (`DatabaseTransactionMgr` sets
    `rowCountReported` to false for the first
    transaction of a partition), so during that interval
    `OlapTable.getRowCountForIndex(..., strict = true)`
    returns -1 for the whole table. The only other source of a row count is
    the statistics record of the table,
    and the truncation had just removed it, so the planner clamps the table
    to one row: joins are ordered and
    distributed for a table that looks empty instead of for the rows that
    were just loaded.
    
    The load now recreates the record itself. While the loading transaction
    becomes visible,
    `AnalysisManager.replayUpdateRowsRecord()` creates the record of a table
    which has none in the state of a
    table whose row count has never been collected, and accumulates the rows
    it reports into it, so the planner
    reads them instead of -1. The record is created with a baseline of 0
    (`updatedRowsBase`) and a collected row
    count of 0 for the base index, so every row loaded afterwards is a delta
    row and the reported row count is
    the number of rows the table holds.
    
    The creation happens in `updateUpdatedRows()`, which is the path the
    master walks when the loading
    transaction becomes visible
    (`DatabaseTransactionMgr.updateCatalogAfterVisible`) and the path a
    follower
    walks while it replays the transaction state
    (`replayUpsertTransactionState`), so every frontend ends up with
    the record. It needs no edit log entry of its own and does not touch the
    transaction state machine: the
    record is a function of the transaction state the journal already
    carries. The whole table `DROP STATS`, the
    schema changes and the truncation keep removing the record as before,
    and the next load recreates it. A
    partition truncation is deliberately left out: the rows it reports are
    the rows it removed, so it does not
    create a record.
    
    Two related fixes come with it, because the record is now the row count
    source in more cases:
    - the delta is only added to an index which keeps one row per base row
    (`TableStatsMeta.keepsOneRowPerBaseRow`): an aggregating rollup, an
    index which merges the rows of a unique
    key table and a filtered materialized index have their own row count and
    must not be reported with the
    physical rows of the base index, and a row count which was never
    collected is not combined with a delta;
    - `StatisticsMetricCollector` decided "not analyzed" by the absence of a
    record. A record now exists for a
    loaded table which was never analyzed, so it decides by the analyze
    timestamp instead.
    
    ### Known limitation
    
    A record which is created for a table that already holds rows, for
    instance right after a whole table
    `DROP STATS` or after a schema change (the truncation case starts from
    an empty table), only knows the rows
    loaded after that point, so the table is estimated with them until the
    backends report the tablets or an
    analysis collects the row count. It is still closer than the unknown row
    count it replaces: a merge key
    table reports the physical row count in that window, which is an upper
    bound of its logical row count, as it
    does in the steady state today.
    
    ### Release note
    
    None
    
    ### Check List (For Author)
    
    - Test: new unit tests in `AnalysisManagerTest` (a load creates the
    record and accumulates its rows, a load
    which reports the rows by table id creates it as well, a replayed load
    creates the same record, an existing
    analyzed record is only accumulated, and an unresolvable table creates
    none) and in `TableStatsMetaTest`
    (the record of a table whose row count was never collected reports the
    loaded rows; the baseline survives
    dropping the column statistics; a legacy record derives it from the
    column statistics; a stale analyze
    snapshot and its baseline cancel out; a user injected row count is not
    counted twice; the reader of the
    snapshot never pairs two states; a row preserving rollup reports the
    loaded rows while an aggregating one
    and a filtered one do not). `AnalysisManagerTest` 21/21,
    `TableStatsMetaTest` 12/12 (checkstyle not
    skipped). Regression: the new suite `test_row_count_after_truncate` (the
    truncate case, an aggregate rollup
    and a projection rollup) and the existing `test_drop_stats_and_truncate`
    both pass. Manual test on a local
    FE and BE cluster with the reproduction above: the scan reports
    cardinality=3 right after the load instead
    of 1, a never analyzed table which receives 4 rows reports 4 instead of
    1, and `SHOW TABLE STATS` shows the
      accumulated rows with an empty analyze time.
    - Behavior changed: Yes. A table whose statistics record was removed
    reports the rows its loads accumulate
    instead of an unknown row count (planned as one row) while the backends
    have not reported the new tablets
    yet; the "not analyzed" metric counts a table by its analyze timestamp.
    - Does this need documentation: No
---
 .../org/apache/doris/alter/SchemaChangeJobV2.java  |  29 +-
 .../apache/doris/catalog/CatalogRecycleBin.java    |   9 +
 .../transaction/CloudGlobalTransactionMgr.java     |  11 +-
 .../doris/nereids/stats/StatsCalculator.java       |  20 +-
 .../plans/commands/ShowTableStatsCommand.java      |  27 +-
 .../doris/statistics/analysis/AnalysisManager.java | 251 +++++++++---
 .../analysis/StatisticsAutoCollector.java          |   4 +-
 .../analysis/StatisticsMetricCollector.java        |   6 +-
 .../doris/statistics/analysis/TableStatsMeta.java  | 150 ++++++-
 .../doris/statistics/analysis/UpdateRowsEvent.java |  11 +-
 .../statistics/repository/StatisticsCleaner.java   |  38 ++
 .../doris/alter/SchemaChangeJobV2StatsTest.java    |  59 +++
 .../doris/nereids/stats/StatsCalculatorTest.java   |  46 +++
 .../statistics/analysis/AnalysisManagerTest.java   | 436 +++++++++++++++++++++
 .../statistics/analysis/TableStatsMetaTest.java    | 288 ++++++++++++++
 .../repository/StatisticsCleanerTest.java          |  84 ++++
 .../query_p0/eager_agg/scalar_agg_pushdown.out     |   8 +-
 .../data/query_p0/hint/multi_leading.out           |   2 +-
 .../data/query_p0/runtimefilterV2/rfv2.out         |  18 +-
 .../tpcds_sf100/noStatsRfPrune/query24.out         |  28 +-
 .../mtmv_p0/ivm/test_ivm_explain_refresh.groovy    |  32 +-
 ...lated_scalar_subquery_to_window_function.groovy |   4 +
 .../query_p0/eager_agg/scalar_agg_pushdown.groovy  |   3 +
 .../tpcds_sf100/noStatsRfPrune/query24.groovy      |   3 +
 .../test_row_count_after_truncate.groovy           | 131 +++++++
 25 files changed, 1594 insertions(+), 104 deletions(-)

diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/alter/SchemaChangeJobV2.java 
b/fe/fe-core/src/main/java/org/apache/doris/alter/SchemaChangeJobV2.java
index b88a394ef27..7d98c8b7916 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/alter/SchemaChangeJobV2.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/alter/SchemaChangeJobV2.java
@@ -56,7 +56,6 @@ import org.apache.doris.nereids.trees.expressions.Cast;
 import org.apache.doris.nereids.types.DataType;
 import org.apache.doris.persist.gson.GsonPostProcessable;
 import org.apache.doris.persist.gson.GsonUtils;
-import org.apache.doris.statistics.analysis.AnalysisManager;
 import org.apache.doris.task.AgentBatchTask;
 import org.apache.doris.task.AgentTask;
 import org.apache.doris.task.AgentTaskExecutor;
@@ -70,6 +69,7 @@ import org.apache.doris.thrift.TTaskType;
 import org.apache.doris.transaction.GlobalTransactionMgr;
 import org.apache.doris.transaction.TransactionState;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.HashBasedTable;
@@ -728,18 +728,39 @@ public class SchemaChangeJobV2 extends AlterJobV2 
implements GsonPostProcessable
             Env.getCurrentEnv().getEditLog().logAlterJob(this);
             this.showJobState = JobState.FINISHED;
             pruneMeta();
+            // The record is removed with the journal entry of FINISHED, i.e. 
still under the table write lock:
+            // a load which becomes visible in between updates (or recreates) 
the record, and this removal would
+            // follow it on the master, while a follower replays FINISHED 
(removing the record) before VISIBLE
+            // (recreating it with the rows of that load). The master would 
then have no record while its
+            // followers kept one, and the row count of the table would differ 
between them.
+            if (!FeConstants.runningUnitTest) {
+                removeTableStatsOnFinished(tbl);
+            }
         } finally {
             tbl.writeUnlock();
         }
         postProcessOriginIndex();
         // Drop table column stats after schema change finished.
         if (!FeConstants.runningUnitTest) {
-            AnalysisManager manager = Env.getCurrentEnv().getAnalysisManager();
-            manager.removeTableStats(tbl.getId());
-            manager.dropStats(tbl, null);
+            Env.getCurrentEnv().getAnalysisManager().dropStats(tbl, null);
         }
     }
 
+    /**
+     * Remove the statistics record of the table when the schema change 
finished.
+     *
+     * <p>It must be called with the table write lock held, in the same 
critical section which journals FINISHED,
+     * so that a load cannot become visible in between: such a load updates or 
recreates the record, and a
+     * follower replays the journal order "FINISHED (remove the record), then 
VISIBLE (recreate it)", so the
+     * master and its followers would disagree about the row count of the 
table.
+     */
+    @VisibleForTesting
+    void removeTableStatsOnFinished(OlapTable tbl) {
+        Preconditions.checkState(tbl.isWriteLockHeldByCurrentThread(),
+                "the statistics of table %s must be removed under its write 
lock", tbl.getName());
+        Env.getCurrentEnv().getAnalysisManager().removeTableStats(tbl.getId());
+    }
+
     private void onFinished(OlapTable tbl) {
         // replace the origin index with shadow index, set index state as 
NORMAL
         for (Partition partition : tbl.getPartitions()) {
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/catalog/CatalogRecycleBin.java 
b/fe/fe-core/src/main/java/org/apache/doris/catalog/CatalogRecycleBin.java
index dcea067295c..cdeee0fb6ee 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/catalog/CatalogRecycleBin.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/catalog/CatalogRecycleBin.java
@@ -275,6 +275,15 @@ public class CatalogRecycleBin extends MasterDaemon 
implements Writable {
         return idToRecycleTime.get(id);
     }
 
+    /**
+     * The table of a recycle bin entry. It keeps the id of the dropped table 
and is the instance which
+     * {@code RECOVER TABLE} registers again, so its lock is the lock of the 
recovered table as well.
+     */
+    public Table getRecycledTable(long tableId) {
+        RecycleTableInfo tableInfo = idToTable.get(tableId);
+        return tableInfo == null ? null : tableInfo.getTable();
+    }
+
     public void setRecycleTimeByIdForReplay(long id, Long recycleTime) {
         idToRecycleTime.put(id, recycleTime);
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
 
b/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
index ba5afbc2f6c..624d41df410 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/cloud/transaction/CloudGlobalTransactionMgr.java
@@ -536,6 +536,15 @@ public class CloudGlobalTransactionMgr implements 
GlobalTransactionMgrIface {
         long dbId = commitTxnResponse.getTxnInfo().getDbId();
         long txnId = commitTxnResponse.getTxnInfo().getTxnId();
         // 1. update rowCountfor AnalysisManager
+        // The partitions the rows were loaded into are handed over as well: a 
report can arrive after those
+        // partitions are gone (a stream load queues its statistics report and 
can return before the frontend
+        // received it, and a whole table TRUNCATE replaces the partitions 
while it keeps the table id), and the
+        // rows of such a report are the rows which were removed.
+        Map<Long, List<Long>> reportedPartitions = Maps.newHashMap();
+        for (int idx = 0; idx < commitTxnResponse.getPartitionIdsCount(); 
++idx) {
+            
reportedPartitions.computeIfAbsent(commitTxnResponse.getTableIds(idx), tableId 
-> Lists.newArrayList())
+                    .add(commitTxnResponse.getPartitionIds(idx));
+        }
         Map<Long, Long> updatedRows = new HashMap<>();
         for (TableStatsPB tableStats : commitTxnResponse.getTableStatsList()) {
             if (tableStats.hasUpdatedRowCount()) {
@@ -545,7 +554,7 @@ public class CloudGlobalTransactionMgr implements 
GlobalTransactionMgrIface {
             }
         }
         Env env = Env.getCurrentEnv();
-        env.getAnalysisManager().updateUpdatedRows(updatedRows);
+        env.getAnalysisManager().updateUpdatedRows(updatedRows, dbId, 
reportedPartitions);
         // 2. update table and partition version
         Map<Long, List<Long>> tablePartitionMap = 
updateVersion(commitTxnResponse);
         // 3. notify partition first load
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java
index e1482c1bdcd..e957907fd62 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/stats/StatsCalculator.java
@@ -150,6 +150,7 @@ import org.apache.doris.statistics.model.Statistics;
 import org.apache.doris.statistics.model.StatisticsBuilder;
 import org.apache.doris.statistics.util.StatisticsUtil;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
@@ -381,14 +382,21 @@ public class StatsCalculator extends 
DefaultPlanVisitor<Statistics, Void> {
     }
 
     /**
-     * returns the sum of deltaRowCount for all selected partitions or for the 
table.
+     * The rows loaded into the base index which the collected column 
statistics don't describe yet, i.e. the
+     * population {@link FilterEstimation#estimate} retries a zero estimate 
with. It is deliberately not
+     * restricted to an index which keeps one row per base row: such an index 
must not add the delta to its own
+     * row count ({@link TableStatsMeta#getRowCountWithDeltaRows} gates that), 
but stale column statistics
+     * affect every index in the same way, and for an index which aggregates 
the rows the delta is an upper
+     * bound of the rows it gained.
      */
-    private long computeDeltaRowCount(OlapScan olapScan) {
+    @VisibleForTesting
+    long computeDeltaRowCount(OlapScan olapScan) {
+        OlapTable olapTable = olapScan.getTable();
         AnalysisManager analysisManager = 
Env.getCurrentEnv().getAnalysisManager();
-        TableStatsMeta tableMeta = 
analysisManager.findTableStatsStatus(olapScan.getTable().getId());
+        TableStatsMeta tableMeta = 
analysisManager.findTableStatsStatus(olapTable.getId());
         long deltaRowCount = 0;
         if (tableMeta != null) {
-            deltaRowCount = 
tableMeta.getBaseIndexDeltaRowCount(olapScan.getTable());
+            deltaRowCount = tableMeta.getBaseIndexDeltaRowCount(olapTable);
         }
         return deltaRowCount;
     }
@@ -471,7 +479,9 @@ public class StatsCalculator extends 
DefaultPlanVisitor<Statistics, Void> {
             rowCount = 
olapTable.getRowCountForIndex(olapScan.getSelectedIndexId(), true);
             if (rowCount == -1) {
                 if (tableMeta != null) {
-                    rowCount = 
tableMeta.getRowCount(olapScan.getSelectedIndexId()) + 
computeDeltaRowCount(olapScan);
+                    // The collected row count and the rows loaded since it 
was collected are read as one
+                    // snapshot.
+                    rowCount = tableMeta.getRowCountWithDeltaRows(olapTable, 
olapScan.getSelectedIndexId());
                 }
             }
         }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowTableStatsCommand.java
 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowTableStatsCommand.java
index 8cb4dc13711..7d265c57ca4 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowTableStatsCommand.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/nereids/trees/plans/commands/ShowTableStatsCommand.java
@@ -242,28 +242,37 @@ public class ShowTableStatsCommand extends ShowCommand {
         row.add(String.valueOf(tableStatistic.updatedRows));
         row.add(String.valueOf(tableStatistic.queriedTimes.get()));
         row.add(String.valueOf(tableStatistic.rowCount));
-        LocalDateTime dateTime =
-                
LocalDateTime.ofInstant(Instant.ofEpochMilli(tableStatistic.updatedTime),
-                java.time.ZoneId.systemDefault());
-        LocalDateTime lastAnalyzeTime =
-                
LocalDateTime.ofInstant(Instant.ofEpochMilli(tableStatistic.lastAnalyzeTime),
-                java.time.ZoneId.systemDefault());
-        row.add(dateTime.format(formatter));
+        row.add(formatStatsTime(tableStatistic.updatedTime, formatter));
         Set<Pair<String, String>> columnsSet = tableStatistic.analyzeColumns();
         Set<Pair<String, String>> newColumnsSet = new HashSet<>();
         for (Pair<String, String> pair : columnsSet) {
             
newColumnsSet.add(Pair.of(Util.getTempTableDisplayName(pair.first), 
pair.second));
         }
         row.add(newColumnsSet.toString());
-        row.add(tableStatistic.jobType.toString());
+        // A record which no analyze job created, for instance the record a 
load creates for a table whose
+        // statistics were dropped, has no trigger to show.
+        row.add(tableStatistic.jobType == null ? "" : 
tableStatistic.jobType.toString());
         row.add(String.valueOf(tableStatistic.partitionChanged.get()));
         row.add(String.valueOf(tableStatistic.userInjected));
         row.add(table == null ? "N/A" : 
String.valueOf(table.autoAnalyzeEnabled()));
-        row.add(lastAnalyzeTime.format(formatter));
+        row.add(formatStatsTime(tableStatistic.lastAnalyzeTime, formatter));
         result.add(row);
         return new ShowResultSet(getMetaData(), result);
     }
 
+    /**
+     * A stats record may exist for a table which has never been analyzed, for 
instance the record a load
+     * creates for a table whose statistics were dropped. There is no 
timestamp to display for those records.
+     */
+    private static String formatStatsTime(long timeInMs, DateTimeFormatter 
formatter) {
+        if (timeInMs == 0) {
+            return "";
+        }
+        LocalDateTime dateTime = 
LocalDateTime.ofInstant(Instant.ofEpochMilli(timeInMs),
+                java.time.ZoneId.systemDefault());
+        return dateTime.format(formatter);
+    }
+
     /**
      * constructPartitionResultSet
      */
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/AnalysisManager.java
 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/AnalysisManager.java
index 914ac3afbbb..1fe368d480f 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/AnalysisManager.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/AnalysisManager.java
@@ -40,6 +40,7 @@ import org.apache.doris.common.Pair;
 import org.apache.doris.common.ThreadPoolManager;
 import org.apache.doris.common.ThreadPoolManager.BlockedPolicy;
 import org.apache.doris.common.io.Writable;
+import org.apache.doris.common.util.MetaLockUtils;
 import org.apache.doris.common.util.Util;
 import org.apache.doris.datasource.CatalogIf;
 import org.apache.doris.datasource.InternalCatalog;
@@ -758,8 +759,7 @@ public class AnalysisManager implements Writable {
         // Remove tableMetaStats if drop whole table stats.
         if ((cols == null || cols.isEmpty()) && (!table.isPartitionedTable() 
|| partitionNamesInfo == null
                 || partitionNamesInfo.isStar() || 
partitionNamesInfo.getPartitionNames() == null)) {
-            removeTableStats(tblId);
-            Env.getCurrentEnv().getEditLog().logDeleteTableStats(new 
TableStatsDeletionLog(tblId));
+            removeTableStatsAndLog(table);
         }
         invalidateLocalStats(catalogId, dbId, tblId, cols, tableStats, 
partitionNamesInfo);
         // Drop stats ddl is master only operation.
@@ -1276,6 +1276,53 @@ public class AnalysisManager implements Writable {
         return idToTblStats.get(tblId);
     }
 
+    /**
+     * The record of the table, created in the state of a table whose row 
count has never been collected when
+     * it doesn't exist yet. A truncation, a whole table DROP STATS and a 
schema change remove the record, so
+     * the rows which are loaded afterwards can only be turned into the row 
count of the table while the
+     * record exists: the planner falls back to it exactly while the backends 
have not reported the row count
+     * of the new tablets yet, which is the interval right after those 
operations.
+     *
+     * <p>Loads run this from the transaction visibility of the load, both on 
the master
+     * ({@code DatabaseTransactionMgr.updateCatalogAfterVisible}) and while a 
follower replays the transaction
+     * state, so the record exists on every frontend without a journal entry 
of its own.
+     *
+     * @return the existing or the created record, or null when the table 
cannot be resolved any more
+     */
+    private TableStatsMeta findOrCreateTableStats(InternalCatalog catalog, 
long dbId, long tableId) {
+        TableStatsMeta tableStats = idToTblStats.get(tableId);
+        if (tableStats != null) {
+            return tableStats;
+        }
+        Optional<Database> dbOption = catalog.getDb(dbId);
+        if (!dbOption.isPresent()) {
+            return null;
+        }
+        Optional<Table> tableOption = dbOption.get().getTable(tableId);
+        if (!tableOption.isPresent() || !(tableOption.get() instanceof 
OlapTable)) {
+            return null;
+        }
+        return findOrCreateTableStats((OlapTable) tableOption.get());
+    }
+
+    private TableStatsMeta findOrCreateTableStats(OlapTable table) {
+        TableStatsMeta tableStats = idToTblStats.get(table.getId());
+        if (tableStats != null) {
+            return tableStats;
+        }
+        TableStatsMeta created = new TableStatsMeta(table);
+        // The insertion is published under the monitor {@link 
#writeTableStats} holds while it writes the
+        // number of records and then the records themselves. A record which 
is inserted in between would be
+        // written although the declared number excludes it, and the readers, 
which trust that number, would
+        // take the extra record as the header of the next module of the 
image. The creation stays outside the
+        // monitor: it only reads catalog metadata and must not be ordered 
with the catalog locks which the
+        // image dump holds before it takes this monitor.
+        synchronized (idToTblStats) {
+            TableStatsMeta existing = idToTblStats.putIfAbsent(table.getId(), 
created);
+            return existing == null ? created : existing;
+        }
+    }
+
     // Invoke this when load transaction finished.
     public void updateUpdatedRows(Map<Long, Map<Long, Long>> tabletRecords, 
long dbId, long txnId) {
         try {
@@ -1298,17 +1345,93 @@ public class AnalysisManager implements Writable {
     }
 
     // Invoke this for cloud version load.
-    public void updateUpdatedRows(Map<Long, Long> updatedRows) {
+    public void updateUpdatedRows(Map<Long, Long> updatedRows, long dbId) {
+        updateUpdatedRows(updatedRows, dbId, Collections.emptyMap());
+    }
+
+    /**
+     * Apply and journal the rows a cloud load reported.
+     *
+     * @param reportedPartitions the partitions each table's rows were loaded 
into, or an empty map when the
+     *         caller has no partition information
+     */
+    public void updateUpdatedRows(Map<Long, Long> updatedRows, long dbId,
+            Map<Long, List<Long>> reportedPartitions) {
+        if (!Env.getCurrentEnv().isMaster() || Env.isCheckpointThread()) {
+            return;
+        }
+        // The record of a table which has none is created while this event is 
applied, and the event is
+        // journaled afterwards. A whole table DROP STATS removes the record 
and journals the deletion, so both
+        // mutations have to be applied in the order their journal entries 
have: without the table lock the
+        // deletion can be applied (and journaled) between the application of 
this event and its journal entry,
+        // and a follower which replays "remove, then add" keeps a record 
which the master no longer has, which
+        // makes the row count of the table differ between the master and its 
followers. The lock is the one
+        // DROP STATS takes and the one finishTransaction holds while a local 
load recreates the record.
+        List<Table> tableList = getTablesOnIdOrder(dbId, updatedRows.keySet());
+        if (!MetaLockUtils.tryWriteLockTablesIfExist(tableList, 10, 
TimeUnit.SECONDS)) {
+            LOG.warn("Failed to lock the tables {} of db {} to record the 
updated rows {}, the row counts stay "
+                    + "unknown until the backends report them", 
updatedRows.keySet(), dbId, updatedRows);
+            return;
+        }
         try {
-            if (!Env.getCurrentEnv().isMaster() || Env.isCheckpointThread()) {
+            // A report can arrive after the partitions it counted are gone: a 
stream load queues the report of
+            // the rows it loaded (`send_stats_to_fe_async`) and can return 
before the frontend received it, and
+            // a whole table TRUNCATE replaces the partitions while it keeps 
the id of the table. The rows of
+            // such a report are the rows which were removed, so they are 
neither applied nor journaled: the
+            // record which the next load recreates would otherwise report a 
table which holds 3 rows as if it
+            // held 103 rows, until the backends report the new tablets.
+            Map<Long, Long> recordedRows = new HashMap<>(updatedRows);
+            recordedRows.keySet().removeIf(tableId -> 
!reportCountsCurrentPartitions(dbId, tableId,
+                    reportedPartitions.getOrDefault(tableId, 
Collections.emptyList())));
+            if (recordedRows.isEmpty()) {
                 return;
             }
-            UpdateRowsEvent updateRowsEvent = new UpdateRowsEvent(updatedRows);
+            UpdateRowsEvent updateRowsEvent = new UpdateRowsEvent(dbId, 
recordedRows);
             replayUpdateRowsRecord(updateRowsEvent);
             logUpdateRowsRecord(updateRowsEvent);
         } catch (Throwable t) {
             LOG.warn("Failed to record update rows.", t);
+        } finally {
+            MetaLockUtils.writeUnlockTables(tableList);
+        }
+    }
+
+    /**
+     * Whether the partitions which a cloud row count report counted still 
belong to the table.
+     *
+     * <p>A stream load queues the report of the rows it loaded and can return 
before the frontend received it,
+     * so a report can arrive after the partitions it counted are gone: a 
whole table TRUNCATE replaces the
+     * partitions of the table and keeps its id, so the rows of such a report 
are the rows the truncation
+     * removed and must not be accumulated into the record which the next load 
recreates.
+     */
+    @VisibleForTesting
+    static boolean reportCountsCurrentPartitions(OlapTable table, 
Collection<Long> reportedPartitionIds) {
+        for (long partitionId : reportedPartitionIds) {
+            if (table.getPartition(partitionId) == null) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean reportCountsCurrentPartitions(long dbId, long 
tableId,
+            Collection<Long> reportedPartitionIds) {
+        if (reportedPartitionIds.isEmpty()) {
+            return true;
         }
+        return Env.getCurrentInternalCatalog().getDb(dbId)
+                .flatMap(db -> db.getTable(tableId))
+                .filter(table -> table instanceof OlapTable)
+                .map(table -> reportCountsCurrentPartitions((OlapTable) table, 
reportedPartitionIds))
+                .orElse(true);
+    }
+
+    private List<Table> getTablesOnIdOrder(long dbId, Collection<Long> 
tableIds) {
+        Optional<Database> db = Env.getCurrentInternalCatalog().getDb(dbId);
+        if (!db.isPresent()) {
+            return Lists.newArrayList();
+        }
+        return 
db.get().getTablesOnIdOrderIfExist(Lists.newArrayList(tableIds));
     }
 
     // Set to true means new partition loaded data
@@ -1355,7 +1478,7 @@ public class AnalysisManager implements Writable {
         InternalCatalog catalog = Env.getCurrentInternalCatalog();
         if (event.getRecords() != null) {
             for (Entry<Long, Long> record : event.getRecords().entrySet()) {
-                TableStatsMeta statsStatus = idToTblStats.get(record.getKey());
+                TableStatsMeta statsStatus = findOrCreateTableStats(catalog, 
event.getDbId(), record.getKey());
                 if (statsStatus != null) {
                     statsStatus.updatedRows.addAndGet(record.getValue());
                 }
@@ -1366,52 +1489,52 @@ public class AnalysisManager implements Writable {
         // Record : TableId -> (TabletId -> update rows)
         if (event.getTabletRecords() != null) {
             for (Entry<Long, Map<Long, Long>> record : 
event.getTabletRecords().entrySet()) {
-                TableStatsMeta statsStatus = idToTblStats.get(record.getKey());
-                if (statsStatus != null) {
-                    Optional<Database> dbOption = 
catalog.getDb(event.getDbId());
-                    if (!dbOption.isPresent()) {
-                        LOG.warn("Database {} does not exist.", 
event.getDbId());
-                        continue;
-                    }
-                    Database database = dbOption.get();
-                    Optional<Table> tableOption = 
database.getTable(record.getKey());
-                    if (!tableOption.isPresent()) {
-                        LOG.warn("Table {} does not exist in DB {}.", 
record.getKey(), event.getDbId());
-                        continue;
-                    }
-                    Table table = tableOption.get();
-                    if (!(table instanceof OlapTable)) {
-                        continue;
-                    }
-                    OlapTable olapTable = (OlapTable) table;
-                    short replicaNum = 
olapTable.getTableProperty().getReplicaAllocation().getTotalReplicaNum();
-                    Map<Long, Long> tabletRows = record.getValue();
-                    if (tabletRows == null || tabletRows.isEmpty()) {
-                        LOG.info("Tablet row count map is empty");
-                        continue;
-                    }
-                    long rowsForAllReplica = 0;
-                    for (Entry<Long, Long> entry : tabletRows.entrySet()) {
-                        rowsForAllReplica += entry.getValue();
-                        if (LOG.isDebugEnabled()) {
-                            LOG.debug("Table id {}, tablet id {}, row count 
{}",
-                                    record.getKey(), entry.getKey(), 
entry.getValue());
-                        }
-                    }
-                    long tableUpdateRows = rowsForAllReplica / replicaNum;
-                    LOG.info("Update rows for table {} is {}, replicaNum is 
{}, "
-                            + "rows for all replica {}, tablets count {}",
-                            olapTable.getName(), tableUpdateRows, replicaNum, 
rowsForAllReplica, tabletRows.size());
-                    statsStatus.updatedRows.addAndGet(tableUpdateRows);
-                    if (StatisticsUtil.enablePartitionAnalyze()) {
-                        updatePartitionRows(olapTable, tabletRows, 
statsStatus, replicaNum);
+                Optional<Database> dbOption = catalog.getDb(event.getDbId());
+                if (!dbOption.isPresent()) {
+                    LOG.warn("Database {} does not exist.", event.getDbId());
+                    continue;
+                }
+                Database database = dbOption.get();
+                Optional<Table> tableOption = 
database.getTable(record.getKey());
+                if (!tableOption.isPresent()) {
+                    LOG.warn("Table {} does not exist in DB {}.", 
record.getKey(), event.getDbId());
+                    continue;
+                }
+                Table table = tableOption.get();
+                if (!(table instanceof OlapTable)) {
+                    continue;
+                }
+                OlapTable olapTable = (OlapTable) table;
+                TableStatsMeta statsStatus = findOrCreateTableStats(olapTable);
+                short replicaNum = 
olapTable.getTableProperty().getReplicaAllocation().getTotalReplicaNum();
+                Map<Long, Long> tabletRows = record.getValue();
+                if (tabletRows == null || tabletRows.isEmpty()) {
+                    LOG.info("Tablet row count map is empty");
+                    continue;
+                }
+                long rowsForAllReplica = 0;
+                for (Entry<Long, Long> entry : tabletRows.entrySet()) {
+                    rowsForAllReplica += entry.getValue();
+                    if (LOG.isDebugEnabled()) {
+                        LOG.debug("Table id {}, tablet id {}, row count {}",
+                                record.getKey(), entry.getKey(), 
entry.getValue());
                     }
                 }
+                long tableUpdateRows = rowsForAllReplica / replicaNum;
+                LOG.info("Update rows for table {} is {}, replicaNum is {}, "
+                        + "rows for all replica {}, tablets count {}",
+                        olapTable.getName(), tableUpdateRows, replicaNum, 
rowsForAllReplica, tabletRows.size());
+                statsStatus.updatedRows.addAndGet(tableUpdateRows);
+                if (StatisticsUtil.enablePartitionAnalyze()) {
+                    updatePartitionRows(olapTable, tabletRows, statsStatus, 
replicaNum);
+                }
             }
             return;
         }
 
-        // Handle truncate table
+        // Handle truncate table. A partition truncation is not a load: the 
rows it reports are the rows it
+        // removed, so it must not create a record for a table which has none, 
and it can only be accumulated
+        // into a record which was created by a load or an analyze.
         if (event.getPartitionToUpdateRows() != null && event.getTableId() > 
0) {
             Map<Long, Long> partRows = event.getPartitionToUpdateRows();
             long totalRows = partRows.values().stream().mapToLong(rows -> 
rows).sum();
@@ -1487,6 +1610,42 @@ public class AnalysisManager implements Writable {
         }
     }
 
+    /**
+     * Remove the statistics record of the table and journal the deletion, 
under the table write lock.
+     *
+     * <p>A load recreates the record of a table which has none while its 
transaction becomes visible: the
+     * transaction is journaled as {@code VISIBLE} and the record is created by
+     * {@code DatabaseTransactionMgr#updateCatalogAfterVisible} afterwards, 
both under the table write lock
+     * which {@code finishTransaction} holds until that method returns. A 
deletion which is applied in
+     * between is replayed by the followers after the creation (the journal 
order is "transaction, then
+     * deletion") while the master applied it before the creation, so the 
master keeps the record the load
+     * created and every follower, and every image built from a follower, ends 
up without it. Removing and
+     * journaling the deletion under the same lock orders it either before the 
transaction was journaled or
+     * after the record was created, which is the order the journal has as 
well, so all frontends agree.
+     *
+     * <p>The lock is deliberately held across the journal write: the record 
creation of a load which is
+     * already past its own journal write must not slip in between the removal 
and its journal entry.
+     */
+    public void removeTableStatsAndLog(TableIf table) {
+        if (!(table instanceof OlapTable)) {
+            // Only a load into an OLAP table recreates the record of a table 
which has none, there is no
+            // creation to be ordered with.
+            removeTableStatsAndLog(table.getId());
+            return;
+        }
+        table.writeLock();
+        try {
+            removeTableStatsAndLog(table.getId());
+        } finally {
+            table.writeUnlock();
+        }
+    }
+
+    private void removeTableStatsAndLog(long tableId) {
+        removeTableStats(tableId);
+        Env.getCurrentEnv().getEditLog().logDeleteTableStats(new 
TableStatsDeletionLog(tableId));
+    }
+
     public Set<Long> getIdToTblStatsKeys() {
         return new HashSet<>(idToTblStats.keySet());
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/StatisticsAutoCollector.java
 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/StatisticsAutoCollector.java
index 52ec6d2bd7e..86101fe588a 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/StatisticsAutoCollector.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/StatisticsAutoCollector.java
@@ -27,7 +27,6 @@ import org.apache.doris.common.DdlException;
 import org.apache.doris.common.Pair;
 import org.apache.doris.common.util.MasterDaemon;
 import org.apache.doris.datasource.plugin.PluginDrivenExternalTable;
-import org.apache.doris.persist.TableStatsDeletionLog;
 import org.apache.doris.statistics.StatisticConstants;
 import org.apache.doris.statistics.analysis.AnalysisInfo.AnalysisMethod;
 import org.apache.doris.statistics.analysis.AnalysisInfo.JobType;
@@ -210,8 +209,7 @@ public class StatisticsAutoCollector extends MasterDaemon {
             LOG.info("Table {} is empty, remove its old stats and skip auto 
analyze it.", table.getName());
             // Remove the table's old stats if exists.
             if (tableStatsStatus != null && 
!tableStatsStatus.isColumnsStatsEmpty()) {
-                manager.removeTableStats(table.getId());
-                Env.getCurrentEnv().getEditLog().logDeleteTableStats(new 
TableStatsDeletionLog(table.getId()));
+                manager.removeTableStatsAndLog(table);
                 manager.dropStats(table, null);
             }
             return false;
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/StatisticsMetricCollector.java
 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/StatisticsMetricCollector.java
index 23d3532ad91..8f6bdb9fc06 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/StatisticsMetricCollector.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/StatisticsMetricCollector.java
@@ -86,7 +86,11 @@ public class StatisticsMetricCollector extends MasterDaemon {
                         if (table.getRowCount() == 0) {
                             tmpEmptyTableCount += 1;
                         }
-                        if 
(analysisManager.findTableStatsStatus(table.getId()) == null) {
+                        // A record exists for a table which was never 
analyzed as well, for instance the record
+                        // a load creates for a table whose statistics were 
dropped. Only a record with an
+                        // analyze timestamp describes collected statistics.
+                        TableStatsMeta tableStatsStatus = 
analysisManager.findTableStatsStatus(table.getId());
+                        if (tableStatsStatus == null || 
tableStatsStatus.updatedTime == 0) {
                             tmpNotAnalyzedTableCount += 1;
                         }
                         // Get all unhealthy columns.
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/TableStatsMeta.java
 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/TableStatsMeta.java
index 99a5285834d..8c0bb2e1e02 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/TableStatsMeta.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/TableStatsMeta.java
@@ -17,7 +17,10 @@
 
 package org.apache.doris.statistics.analysis;
 
+import org.apache.doris.catalog.AggregateType;
 import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.MaterializedIndexMeta;
 import org.apache.doris.catalog.OlapTable;
 import org.apache.doris.catalog.TableIf;
 import org.apache.doris.common.Pair;
@@ -105,6 +108,14 @@ public class TableStatsMeta implements Writable, 
GsonPostProcessable {
     @SerializedName("irc")
     private ConcurrentMap<Long, Long> indexesRowCount = new 
ConcurrentHashMap<>();
 
+    // The value of updatedRows when indexesRowCount was collected, i.e. the 
number of rows the collected row
+    // count already includes. The rows loaded after that point are the delta 
row count of the table. It is
+    // kept here, and not derived from colToColStatsMeta, so that it survives 
dropping the column statistics
+    // and so that a record which has never been analyzed has a baseline of 
its own. -1 means no row count has
+    // ever been collected from the table, the baseline is then derived from 
the column statistics.
+    @SerializedName("updatedRowsBase")
+    private final AtomicLong updatedRowsBase = new AtomicLong(-1);
+
     @VisibleForTesting
     public TableStatsMeta() {
         ctlId = 0;
@@ -130,6 +141,68 @@ public class TableStatsMeta implements Writable, 
GsonPostProcessable {
         update(analyzedJob, table);
     }
 
+    /**
+     * Create the record of a table which doesn't have one yet, in the state 
of a table whose row count has
+     * never been collected. The rows loaded into the table are accumulated by
+     * {@link AnalysisManager#replayUpdateRowsRecord} while the record exists, 
so a record which a truncation,
+     * a DROP STATS or a schema change removed has to be recreated by the next 
load, otherwise these rows can
+     * never be turned into a row count: the planner sees them neither in the 
collected row count nor in the
+     * row count the backends report for the new tablets during the reporting 
interval.
+     */
+    public TableStatsMeta(OlapTable table) {
+        this.ctlId = table.getDatabase().getCatalog().getId();
+        this.ctlName = table.getDatabase().getCatalog().getName();
+        this.dbId = table.getDatabase().getId();
+        this.dbName = table.getDatabase().getFullName();
+        this.tblId = table.getId();
+        this.tblName = table.getName();
+        this.idxId = -1;
+        // Nothing has been collected for this table, and every index whose 
row count follows the base index
+        // is known to be empty: the rows loaded from now on are all delta 
rows.
+        this.indexesRowCount = buildEmptyIndexRowCount(table);
+        this.updatedRowsBase.set(0);
+    }
+
+    private static ConcurrentMap<Long, Long> buildEmptyIndexRowCount(OlapTable 
table) {
+        ConcurrentMap<Long, Long> indexRowCount = new ConcurrentHashMap<>();
+        for (Long indexId : table.getIndexIdList()) {
+            if (keepsOneRowPerBaseRow(table, indexId)) {
+                indexRowCount.put(indexId, 0L);
+            }
+        }
+        return indexRowCount;
+    }
+
+    /**
+     * Whether the rows loaded into the base index are the rows of this index 
as well. That holds for the base
+     * index itself and for an index which keeps one row per base row, i.e. a 
duplicate key index whose columns
+     * are all plain. An index which aggregates, or which merges the rows of a 
unique key table, has a smaller
+     * row count of its own, so the rows loaded into the base index must not 
be added to it.
+     */
+    public static boolean keepsOneRowPerBaseRow(OlapTable table, long indexId) 
{
+        if (indexId == table.getBaseIndexId()) {
+            return true;
+        }
+        MaterializedIndexMeta indexMeta = table.getIndexMetaByIndexId(indexId);
+        if (indexMeta == null || indexMeta.getKeysType() != KeysType.DUP_KEYS) 
{
+            return false;
+        }
+        if (indexMeta.getWhereClause() != null) {
+            // A filtered materialized index only holds the rows which satisfy 
its predicate, it does not keep
+            // one row per base row even though it inherits the key type of 
the base table.
+            return false;
+        }
+        for (Column column : indexMeta.getSchema()) {
+            // A key column has no aggregation type at all, the value columns 
of a duplicate key index are
+            // NONE. Neither of them merges rows, only an aggregating column 
does.
+            AggregateType aggregationType = column.getAggregationType();
+            if (aggregationType != null && aggregationType != 
AggregateType.NONE) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     @Override
     public void write(DataOutput out) throws IOException {
         String json = GsonUtils.GSON.toJson(this);
@@ -161,7 +234,13 @@ public class TableStatsMeta implements Writable, 
GsonPostProcessable {
         return colToColStatsMeta.keySet();
     }
 
-    public void update(AnalysisInfo analyzedJob, TableIf tableIf) {
+    /**
+     * Apply the outcome of an analyze job to this record. The transition runs 
under the monitor of this
+     * record, which {@link #getRowCountWithDeltaRows} also takes, so that a 
planner reading the row count of
+     * the table without holding its lock doesn't pair the collected row count 
of this analysis with the
+     * baseline of another one.
+     */
+    public synchronized void update(AnalysisInfo analyzedJob, TableIf tableIf) 
{
         updatedTime = analyzedJob.tblUpdateTime;
         lastAnalyzeTime = analyzedJob.createTime;
         if (analyzedJob.userInject) {
@@ -194,6 +273,21 @@ public class TableStatsMeta implements Writable, 
GsonPostProcessable {
         if (tableIf != null) {
             if (tableIf instanceof OlapTable) {
                 OlapTable olapTable = (OlapTable) tableIf;
+                // The collected row counts published below already include 
the rows which had been loaded when
+                // the job was built, remember how many they were, they are 
not delta rows. The baseline may
+                // only advance together with the collected base index row 
count, an analysis of another index
+                // (a materialized view) doesn't touch it.
+                // NOTICE: the baseline is published before the collected 
counts. They are both read without
+                // the table lock by the planner, and a reader which pairs the 
collected count of this analysis
+                // with the baseline of the previous one counts the rows 
loaded since that previous analysis
+                // twice.
+                if 
(analyzedJob.indexesRowCount.containsKey(olapTable.getBaseIndexId())) {
+                    // A row count supplied by the user replaces the collected 
one and describes the table as
+                    // of now, so the rows loaded so far are part of it and 
the baseline has to move to the
+                    // current value. Keeping the older baseline would count 
those rows again once DROP STATS
+                    // clears userInjected.
+                    updatedRowsBase.set(analyzedJob.userInject ? 
updatedRows.get() : analyzedJob.updateRows);
+                }
                 indexesRowCount.putAll(analyzedJob.indexesRowCount);
                 clearStaleIndexRowCount(olapTable);
                 if (analyzedJob.jobColumns.containsAll(
@@ -254,8 +348,28 @@ public class TableStatsMeta implements Writable, 
GsonPostProcessable {
         indexesRowCount.put(indexId, rowCount);
     }
 
+    // For unit test only. Simulate a record written before updatedRowsBase 
was recorded in the table stats.
+    protected void clearUpdatedRowsBaseForTest() {
+        updatedRowsBase.set(-1);
+    }
+
+    // For unit test only.
+    protected void setUpdatedRowsBaseForTest(long updatedRowsBase) {
+        this.updatedRowsBase.set(updatedRowsBase);
+    }
+
     public long getBaseIndexDeltaRowCount(OlapTable table) {
-        if (colToColStatsMeta == null || colToColStatsMeta.isEmpty() || 
userInjected) {
+        if (userInjected) {
+            return 0;
+        }
+        long collectedRowCountBase = updatedRowsBase.get();
+        if (collectedRowCountBase >= 0) {
+            return updatedRows.get() - collectedRowCountBase;
+        }
+        // A record written before updatedRowsBase existed has no baseline of 
its own. Derive it from the
+        // collected column statistics, which is where it used to live. Once 
they are all dropped the baseline
+        // is unknown, so no row is reported as a delta row.
+        if (colToColStatsMeta == null || colToColStatsMeta.isEmpty()) {
             return 0;
         }
         long maxUpdateRows = 0;
@@ -268,6 +382,38 @@ public class TableStatsMeta implements Writable, 
GsonPostProcessable {
         return updatedRows.get() - maxUpdateRows;
     }
 
+    /**
+     * The row count of the index together with the rows loaded since it was 
collected, i.e. the row count of
+     * the table. Both are read by the planner without the table lock, for 
instance while it plans a direct
+     * scan of a materialized view, so they have to come from the same state 
of this record: a collected row
+     * count paired with the baseline of another analysis would count rows 
twice or miss them. The rows loaded
+     * while this call runs are not part of the snapshot, whichever state 
reads them accumulates them in
+     * {@link #updatedRows} and reports them as delta rows.
+     */
+    public synchronized long getRowCountWithDeltaRows(OlapTable table, long 
indexId) {
+        long rowCount = getRowCount(indexId);
+        if (!keepsOneRowPerBaseRow(table, indexId)) {
+            // The index aggregates, or it merges the rows of a unique key 
table: it has its own, smaller row
+            // count, and the rows loaded into the base index would overstate 
it.
+            return rowCount;
+        }
+        if (rowCount == TableIf.UNKNOWN_ROW_COUNT) {
+            // A row-preserving index which was added after the row counts 
were collected has no entry of
+            // its own, for instance the rollup of an ALTER TABLE ADD ROLLUP: 
the record describes a table
+            // without that index. It keeps one row per base row, so the 
collected row count of the base
+            // index is its row count as well. Without this fallback the index 
would be planned at one row
+            // until the backends report it for the first time, although the 
base index has a usable count.
+            rowCount = getRowCount(table.getBaseIndexId());
+        }
+        if (rowCount == TableIf.UNKNOWN_ROW_COUNT) {
+            // Neither the index nor the base index has a collected row count. 
The delta rows are relative to
+            // a collection which does not exist, so no row count can be 
derived from them and the planner
+            // treats the unknown row count as no information.
+            return rowCount;
+        }
+        return rowCount + getBaseIndexDeltaRowCount(table);
+    }
+
     public boolean isColumnsStatsEmpty() {
         return colToColStatsMeta == null || colToColStatsMeta.isEmpty();
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/UpdateRowsEvent.java
 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/UpdateRowsEvent.java
index dce8c277e69..fd103c5b2c4 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/UpdateRowsEvent.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/analysis/UpdateRowsEvent.java
@@ -46,9 +46,18 @@ public class UpdateRowsEvent implements Writable {
     private final long tableId;
 
     public UpdateRowsEvent(Map<Long, Long> records) {
+        this(-1, records);
+    }
+
+    /**
+     * For a load which reports the rows of a table by table id instead of by 
tablet. The database id comes
+     * first because the tablet keyed constructor above already takes a map 
followed by a long, and both
+     * would have the same erasure otherwise.
+     */
+    public UpdateRowsEvent(long dbId, Map<Long, Long> records) {
         this.records = records;
         this.tabletRecords = null;
-        this.dbId = -1;
+        this.dbId = dbId;
         this.partitionToUpdateRows = null;
         this.tableId = -1;
     }
diff --git 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/repository/StatisticsCleaner.java
 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/repository/StatisticsCleaner.java
index 429d25e816f..237b400fa12 100644
--- 
a/fe/fe-core/src/main/java/org/apache/doris/statistics/repository/StatisticsCleaner.java
+++ 
b/fe/fe-core/src/main/java/org/apache/doris/statistics/repository/StatisticsCleaner.java
@@ -34,6 +34,7 @@ import org.apache.doris.statistics.analysis.AnalysisManager;
 import org.apache.doris.statistics.analysis.TableStatsMeta;
 import org.apache.doris.statistics.util.StatisticsUtil;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Maps;
 import org.apache.commons.collections4.CollectionUtils;
 import org.apache.commons.text.StringSubstitutor;
@@ -140,6 +141,13 @@ public class StatisticsCleaner extends MasterDaemon {
                 }
                 LOG.info("Table {}.{}.{} with id {} not exist, remove its 
table stats record.",
                         stats.ctlName, stats.dbName, stats.tblName, id);
+                TableIf recycledTable = 
Env.getCurrentRecycleBin().getRecycledTable(id);
+                if (recycledTable instanceof OlapTable) {
+                    removeRecycledTableStats((OlapTable) recycledTable, 
analysisManager, internalCatalog);
+                    continue;
+                }
+                // No table lock: the table is gone and cannot be recovered, 
so no load can recreate the record
+                // of this id while the deletion is journaled.
                 analysisManager.removeTableStats(id);
                 Env.getCurrentEnv().getEditLog().logDeleteTableStats(new 
TableStatsDeletionLog(id));
             } catch (Exception e) {
@@ -148,6 +156,36 @@ public class StatisticsCleaner extends MasterDaemon {
         }
     }
 
+    /**
+     * Remove the record of a table which is in the recycle bin and journal 
the deletion, under the table write
+     * lock.
+     *
+     * <p>`RECOVER TABLE` registers the recycled table again with its original 
id, and the load which then
+     * recreates the record of that id is published under the table write lock 
(`finishTransaction` holds it
+     * until `updateCatalogAfterVisible` returned). Removing and journaling 
the deletion under the same lock
+     * orders it either before the recovery and the load, which journal the 
creation after this deletion, or
+     * after them, in which case the record of that load is the one which is 
removed: the master and its
+     * followers then apply the mutations in the order of the journal. Without 
the lock the deletion could be
+     * journaled after a load recreated the record, and a follower would 
replay "remove" after "create" while
+     * the master applied it before, which leaves the master with a record 
that its followers removed.
+     */
+    @VisibleForTesting
+    void removeRecycledTableStats(OlapTable recycledTable, AnalysisManager 
analysisManager,
+            InternalCatalog internalCatalog) {
+        recycledTable.writeLock();
+        try {
+            if (tableExistInInternalCatalog(internalCatalog, 
recycledTable.getId())) {
+                // The table was recovered after its record was found 
orphaned, keep its statistics.
+                return;
+            }
+            analysisManager.removeTableStats(recycledTable.getId());
+            Env.getCurrentEnv().getEditLog().logDeleteTableStats(
+                    new TableStatsDeletionLog(recycledTable.getId()));
+        } finally {
+            recycledTable.writeUnlock();
+        }
+    }
+
     private boolean tableExistInInternalCatalog(InternalCatalog 
internalCatalog, long tableId) {
         List<Long> dbIds = internalCatalog.getDbIds();
         for (long dbId : dbIds) {
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeJobV2StatsTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeJobV2StatsTest.java
new file mode 100644
index 00000000000..d5f94088f21
--- /dev/null
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/alter/SchemaChangeJobV2StatsTest.java
@@ -0,0 +1,59 @@
+// 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.
+
+package org.apache.doris.alter;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.statistics.analysis.AnalysisManager;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+public class SchemaChangeJobV2StatsTest {
+
+    /**
+     * The statistics record of a table is removed when the schema change 
finished, together with the journal
+     * entry of FINISHED. A load which becomes visible in between updates or 
recreates the record, and a follower
+     * replays "FINISHED, then VISIBLE", so the removal has to happen under 
the table write lock which the load
+     * takes as well.
+     */
+    @Test
+    public void testRemoveTableStatsOnFinishedRequiresTheTableWriteLock() {
+        Env env = Mockito.mock(Env.class);
+        AnalysisManager analysisManager = Mockito.mock(AnalysisManager.class);
+        Mockito.when(env.getAnalysisManager()).thenReturn(analysisManager);
+        OlapTable table = Mockito.mock(OlapTable.class);
+        Mockito.when(table.getId()).thenReturn(10001L);
+        Mockito.when(table.getName()).thenReturn("t");
+        SchemaChangeJobV2 job = new SchemaChangeJobV2("sql", 1L, 2L, 10001L, 
"t", 10000L);
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+
+            
Mockito.when(table.isWriteLockHeldByCurrentThread()).thenReturn(false);
+            Assertions.assertThrows(IllegalStateException.class, () -> 
job.removeTableStatsOnFinished(table));
+            Mockito.verify(analysisManager, 
Mockito.never()).removeTableStats(Mockito.anyLong());
+
+            
Mockito.when(table.isWriteLockHeldByCurrentThread()).thenReturn(true);
+            job.removeTableStatsOnFinished(table);
+            Mockito.verify(analysisManager).removeTableStats(10001L);
+        }
+    }
+}
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/stats/StatsCalculatorTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/stats/StatsCalculatorTest.java
index f5bbc72e3db..90df4ecc4ae 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/nereids/stats/StatsCalculatorTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/nereids/stats/StatsCalculatorTest.java
@@ -19,11 +19,15 @@ package org.apache.doris.nereids.stats;
 
 import org.apache.doris.analysis.IntLiteral;
 import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.DatabaseIf;
 import org.apache.doris.catalog.Env;
+import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.MaterializedIndexMeta;
 import org.apache.doris.catalog.OlapTable;
 import org.apache.doris.catalog.Partition;
 import org.apache.doris.catalog.PrimitiveType;
 import org.apache.doris.common.Pair;
+import org.apache.doris.datasource.CatalogIf;
 import org.apache.doris.nereids.CascadesContext;
 import org.apache.doris.nereids.memo.Group;
 import org.apache.doris.nereids.memo.GroupExpression;
@@ -41,6 +45,7 @@ import org.apache.doris.nereids.trees.plans.GroupPlan;
 import org.apache.doris.nereids.trees.plans.JoinType;
 import org.apache.doris.nereids.trees.plans.LimitPhase;
 import org.apache.doris.nereids.trees.plans.Plan;
+import org.apache.doris.nereids.trees.plans.algebra.OlapScan;
 import org.apache.doris.nereids.trees.plans.algebra.SetOperation.Qualifier;
 import org.apache.doris.nereids.trees.plans.logical.LogicalAggregate;
 import org.apache.doris.nereids.trees.plans.logical.LogicalCatalogRelation;
@@ -58,6 +63,7 @@ import org.apache.doris.nereids.util.PlanConstructor;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.qe.SessionVariable;
 import org.apache.doris.statistics.analysis.AnalysisManager;
+import org.apache.doris.statistics.analysis.TableStatsMeta;
 import org.apache.doris.statistics.cache.StatisticsCache;
 import org.apache.doris.statistics.model.ColumnStatistic;
 import org.apache.doris.statistics.model.ColumnStatisticBuilder;
@@ -382,6 +388,46 @@ public class StatsCalculatorTest {
         }
     }
 
+    @Test
+    public void testDeltaRowCountIsReportedForNonRowPreservingIndex() {
+        long tableId = 100L;
+        long rollupIndexId = 200L;
+        OlapTable table = Mockito.mock(OlapTable.class);
+        DatabaseIf db = Mockito.mock(DatabaseIf.class);
+        CatalogIf catalog = Mockito.mock(CatalogIf.class);
+        Mockito.doReturn(db).when(table).getDatabase();
+        Mockito.doReturn(catalog).when(db).getCatalog();
+        Mockito.doReturn(tableId).when(table).getId();
+        
Mockito.doReturn(Collections.<Long>emptyList()).when(table).getIndexIdList();
+        // The scanned index aggregates the rows of the base index, it does 
not keep one row per base row.
+        MaterializedIndexMeta rollupMeta = 
Mockito.mock(MaterializedIndexMeta.class);
+        Mockito.doReturn(KeysType.AGG_KEYS).when(rollupMeta).getKeysType();
+        
Mockito.doReturn(rollupMeta).when(table).getIndexMetaByIndexId(rollupIndexId);
+
+        // The record a load creates: nothing has been collected yet, every 
loaded row is a delta row.
+        TableStatsMeta tableStats = new TableStatsMeta(table);
+        tableStats.updatedRows.set(100);
+
+        AnalysisManager analysisManager = Mockito.mock(AnalysisManager.class);
+        
Mockito.doReturn(tableStats).when(analysisManager).findTableStatsStatus(tableId);
+        Env env = Mockito.mock(Env.class);
+        Mockito.doReturn(analysisManager).when(env).getAnalysisManager();
+
+        OlapScan scan = Mockito.mock(OlapScan.class);
+        Mockito.doReturn(table).when(scan).getTable();
+        Mockito.doReturn(rollupIndexId).when(scan).getSelectedIndexId();
+
+        try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+            mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+            // These rows are not the row count of an index which does not 
keep one row per base row, they must
+            // not be added to it (TableStatsMeta#getRowCountWithDeltaRows 
gates that). They are still the
+            // population FilterEstimation retries a zero estimate with, for 
every index: stale column
+            // statistics are not index specific.
+            Assertions.assertEquals(100L,
+                    new StatsCalculator((CascadesContext) 
null).computeDeltaRowCount(scan));
+        }
+    }
+
     @Test
     public void testLimit() {
         List<String> qualifier = ImmutableList.of("test", "t");
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/statistics/analysis/AnalysisManagerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/statistics/analysis/AnalysisManagerTest.java
index ec030d63e34..d206daa759d 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/statistics/analysis/AnalysisManagerTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/statistics/analysis/AnalysisManagerTest.java
@@ -23,8 +23,11 @@ import org.apache.doris.catalog.Database;
 import org.apache.doris.catalog.DatabaseIf;
 import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.Partition;
 import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.catalog.ReplicaAllocation;
 import org.apache.doris.catalog.TableIf;
+import org.apache.doris.catalog.TableProperty;
 import org.apache.doris.catalog.info.TableNameInfo;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.DdlException;
@@ -40,6 +43,7 @@ import 
org.apache.doris.nereids.trees.plans.commands.KillAnalyzeJobCommand;
 import org.apache.doris.nereids.types.IntegerType;
 import org.apache.doris.nereids.util.MoreFieldsThread;
 import org.apache.doris.persist.EditLog;
+import org.apache.doris.persist.TableStatsDeletionLog;
 import org.apache.doris.qe.ConnectContext;
 import org.apache.doris.statistics.analysis.AnalysisInfo.AnalysisMethod;
 import org.apache.doris.statistics.analysis.AnalysisInfo.AnalysisType;
@@ -50,11 +54,19 @@ import org.apache.doris.statistics.util.StatisticsUtil;
 import org.apache.doris.thrift.TQueryColumn;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutput;
+import java.io.DataOutputStream;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -64,7 +76,11 @@ import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
 
 // CHECKSTYLE OFF
 public class AnalysisManagerTest {
@@ -691,4 +707,424 @@ public class AnalysisManagerTest {
         
Mockito.when(command.getAnalyzeProperties()).thenReturn(analyzeProperties);
         return command;
     }
+
+    // A table of the given database which reports one replica for the rows a 
load reports.
+    private OlapTable mockOlapTableInReplica(long tableId, String name, 
Database db) {
+        OlapTable table = Mockito.mock(OlapTable.class);
+        Mockito.when(table.getId()).thenReturn(tableId);
+        Mockito.when(table.getBaseIndexId()).thenReturn(10001L);
+        
Mockito.when(table.getIndexIdList()).thenReturn(ImmutableList.of(10001L));
+        Mockito.when(table.getName()).thenReturn(name);
+        Mockito.when(table.getDatabase()).thenReturn(db);
+        TableProperty tableProperty = Mockito.mock(TableProperty.class);
+        ReplicaAllocation replicaAllocation = 
Mockito.mock(ReplicaAllocation.class);
+        
Mockito.when(tableProperty.getReplicaAllocation()).thenReturn(replicaAllocation);
+        
Mockito.when(replicaAllocation.getTotalReplicaNum()).thenReturn((short) 1);
+        Mockito.when(table.getTableProperty()).thenReturn(tableProperty);
+        db.registerTable(table);
+        return table;
+    }
+
+    /**
+     * A load recreates the statistics record of a table whose record was 
removed, e.g. by TRUNCATE TABLE, and
+     * accumulates the loaded rows into it. The record is created on the 
transaction visibility of the load,
+     * which the master applies in {@code 
DatabaseTransactionMgr.updateCatalogAfterVisible} and a follower
+     * applies while it replays the transaction state, so the row count is 
reported on every frontend without
+     * an edit log entry of its own.
+     */
+    @Test
+    public void testLoadCreatesTheStatisticsRecordOfATableWithoutOne() throws 
Exception {
+        AnalysisManager manager = Mockito.spy(new AnalysisManager());
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20001L, "test_load_creates_stats");
+        catalog.unprotectCreateDb(db);
+        OlapTable table = mockOlapTableInReplica(30001L, "t_load", db);
+
+        Map<Long, Map<Long, Long>> tabletRecords = Maps.newHashMap();
+        tabletRecords.put(30001L, ImmutableMap.of(1L, 3L, 2L, 2L));
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+            // What the master does when the loading transaction becomes 
visible.
+            manager.updateUpdatedRows(tabletRecords, db.getId(), 0L);
+
+            // The record exists with the rows of the load, and the planner 
reads them as the row count of the
+            // table while the backends have not reported the new tablets yet.
+            TableStatsMeta record = manager.findTableStatsStatus(30001L);
+            Assertions.assertNotNull(record);
+            Assertions.assertEquals(5, record.updatedRows.get());
+            Assertions.assertEquals(5, record.getRowCountWithDeltaRows(table, 
10001L));
+            Assertions.assertTrue(record.isColumnsStatsEmpty());
+
+            // A record which exists is accumulated, not replaced.
+            manager.updateUpdatedRows(tabletRecords, db.getId(), 0L);
+            Assertions.assertSame(record, 
manager.findTableStatsStatus(30001L));
+            Assertions.assertEquals(10, record.updatedRows.get());
+        }
+    }
+
+    @Test
+    public void testReplayOfALoadCreatesTheSameStatisticsRecord() throws 
Exception {
+        // A follower applies the row counts of a load while it replays the 
transaction state, through the very
+        // same method. A table whose record the truncation removed gets the 
same record there.
+        AnalysisManager manager = Mockito.spy(new AnalysisManager());
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20001L, "test_load_replay_creates_stats");
+        catalog.unprotectCreateDb(db);
+        OlapTable table = mockOlapTableInReplica(30002L, "t_replay", db);
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+            Map<Long, Map<Long, Long>> tabletRecords = Maps.newHashMap();
+            tabletRecords.put(30002L, ImmutableMap.of(1L, 4L));
+            manager.replayUpdateRowsRecord(new UpdateRowsEvent(tabletRecords, 
db.getId()));
+        }
+
+        TableStatsMeta record = manager.findTableStatsStatus(30002L);
+        Assertions.assertNotNull(record);
+        Assertions.assertEquals(4, record.updatedRows.get());
+        Assertions.assertEquals(4, record.getRowCountWithDeltaRows(table, 
10001L));
+    }
+
+    @Test
+    public void testLoadDoesNotTouchTheCollectedRowCountOfAnExistingRecord() 
throws Exception {
+        AnalysisManager manager = Mockito.spy(new AnalysisManager());
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20001L, "test_load_keeps_stats");
+        catalog.unprotectCreateDb(db);
+        OlapTable table = mockOlapTableInReplica(30003L, "t_keeps", db);
+
+        // The record an analysis left: 100 collected rows, no delta.
+        TableStatsMeta analyzed = new TableStatsMeta(table);
+        analyzed.addIndexRowForTest(10001L, 100);
+        analyzed.rowCount = 100;
+        analyzed.updatedRows.set(100);
+        analyzed.setUpdatedRowsBaseForTest(100);
+        manager.replayUpdateTableStatsStatus(analyzed);
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+            Map<Long, Map<Long, Long>> tabletRecords = Maps.newHashMap();
+            tabletRecords.put(30003L, ImmutableMap.of(1L, 7L));
+            manager.updateUpdatedRows(tabletRecords, db.getId(), 0L);
+        }
+
+        TableStatsMeta record = manager.findTableStatsStatus(30003L);
+        Assertions.assertSame(analyzed, record);
+        Assertions.assertEquals(100, record.getRowCount(10001L));
+        Assertions.assertEquals(107, record.updatedRows.get());
+        Assertions.assertEquals(107, record.getRowCountWithDeltaRows(table, 
10001L));
+    }
+
+    @Test
+    public void testALoadWhichReportsRowsByTableIdCreatesTheRecordAsWell() 
throws Exception {
+        // A cloud commit reports the rows of a transaction by table id 
instead of by tablet, and journals the
+        // event, so the follower replays it through this branch as well.
+        AnalysisManager manager = Mockito.spy(new AnalysisManager());
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20001L, "test_load_by_table_id");
+        catalog.unprotectCreateDb(db);
+        OlapTable table = mockOlapTableInReplica(30004L, "t_by_table", db);
+
+        Map<Long, Long> records = Maps.newHashMap();
+        records.put(30004L, 6L);
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+            manager.replayUpdateRowsRecord(new UpdateRowsEvent(db.getId(), 
records));
+        }
+
+        TableStatsMeta record = manager.findTableStatsStatus(30004L);
+        Assertions.assertNotNull(record);
+        Assertions.assertEquals(6, record.updatedRows.get());
+        Assertions.assertEquals(6, record.getRowCountWithDeltaRows(table, 
10001L));
+    }
+
+    @Test
+    public void testLoadOfAnUnresolvableTableDoesNotCreateARecord() throws 
Exception {
+        AnalysisManager manager = Mockito.spy(new AnalysisManager());
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20001L, "test_load_missing_table");
+        catalog.unprotectCreateDb(db);
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+            Map<Long, Map<Long, Long>> tabletRecords = Maps.newHashMap();
+            tabletRecords.put(39999L, ImmutableMap.of(1L, 1L));
+            manager.updateUpdatedRows(tabletRecords, db.getId(), 0L);
+        }
+        Assertions.assertNull(manager.findTableStatsStatus(39999L));
+    }
+
+    private OlapTable mockOlapTableForRemoval(long tableId, String name, 
Database db, List<String> calls) {
+        OlapTable table = mockOlapTableInReplica(tableId, name, db);
+        // The lock is not taken by the mock, record the calls instead: the 
deletion has to be journaled
+        // between them.
+        Mockito.doAnswer(invocation -> {
+            calls.add("writeLock");
+            return null;
+        }).when(table).writeLock();
+        Mockito.doAnswer(invocation -> {
+            calls.add("writeUnlock");
+            return null;
+        }).when(table).writeUnlock();
+        return table;
+    }
+
+    /**
+     * The record of a table which has none is recreated by the load that 
makes a transaction visible, in
+     * {@code DatabaseTransactionMgr.updateCatalogAfterVisible}, which runs 
under the table write lock until it
+     * returns. A deletion which is applied between the journaling of that 
transaction and the recreation is
+     * replayed after the creation on every follower while the master applied 
it before the creation, so the
+     * master would keep a record which no follower has. The deletion is 
therefore applied and journaled under
+     * the same lock, and the journal entry is written before the lock is 
released.
+     */
+    @Test
+    public void testRemoveTableStatsAndLogHoldsTheTableLock() {
+        AnalysisManager manager = new AnalysisManager();
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20001L, "test_remove_table_stats_lock");
+        catalog.unprotectCreateDb(db);
+        List<String> calls = new ArrayList<>();
+        OlapTable table = mockOlapTableForRemoval(30005L, "t_remove_stats", 
db, calls);
+        Env env = Mockito.mock(Env.class);
+        EditLog editLog = Mockito.mock(EditLog.class);
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+            
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+            Mockito.when(env.getEditLog()).thenReturn(editLog);
+            Mockito.doAnswer(invocation -> {
+                calls.add("logDeleteTableStats");
+                // The record is already removed, and the lock is still held.
+                Assertions.assertNull(manager.findTableStatsStatus(30005L));
+                return null;
+            
}).when(editLog).logDeleteTableStats(Mockito.any(TableStatsDeletionLog.class));
+
+            manager.replayUpdateTableStatsStatus(new TableStatsMeta(table));
+            manager.removeTableStatsAndLog(table);
+
+            Assertions.assertEquals(ImmutableList.of("writeLock", 
"logDeleteTableStats", "writeUnlock"), calls);
+            Assertions.assertNull(manager.findTableStatsStatus(30005L));
+        }
+    }
+
+    @Test
+    public void testRemoveTableStatsAndLogDoesNotLockANonOlapTable() {
+        AnalysisManager manager = new AnalysisManager();
+        Env env = Mockito.mock(Env.class);
+        EditLog editLog = Mockito.mock(EditLog.class);
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+            Mockito.when(env.getEditLog()).thenReturn(editLog);
+
+            TableIf table = Mockito.mock(TableIf.class);
+            Mockito.when(table.getId()).thenReturn(30006L);
+
+            manager.removeTableStatsAndLog(table);
+
+            // A load only recreates the record of an OLAP table, there is no 
creation to be ordered with.
+            Mockito.verify(table, Mockito.never()).writeLock();
+            
Mockito.verify(editLog).logDeleteTableStats(Mockito.any(TableStatsDeletionLog.class));
+        }
+    }
+    // A record whose serialization waits, so that a test can publish another 
record while the image of the
+    // statistics map is written. The latches are transient: they are not part 
of the image.
+    private static class BlockingTableStatsMeta extends TableStatsMeta {
+        private final transient CountDownLatch imageWriting = new 
CountDownLatch(1);
+        private final transient CountDownLatch resumeImage = new 
CountDownLatch(1);
+
+        @Override
+        public void write(DataOutput out) throws IOException {
+            imageWriting.countDown();
+            try {
+                resumeImage.await(30, TimeUnit.SECONDS);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            super.write(out);
+        }
+    }
+
+    /**
+     * The image writes the number of records of the statistics map and then 
the records, and the reader trusts
+     * that number. A record which a load publishes while the image is 
written, for instance from the commit
+     * report of a cloud backend, must therefore not be inserted between the 
two: it would be written although
+     * the declared number excludes it, and the reader would take the extra 
record as the header of the next
+     * module of the image.
+     */
+    @Test
+    public void testRecordInsertedWhileTheImageIsWrittenWaitsForTheStatsMap() 
throws Exception {
+        AnalysisManager manager = new AnalysisManager();
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20002L, "test_stats_map_lock");
+        catalog.unprotectCreateDb(db);
+        OlapTable table = mockOlapTableInReplica(30008L, "t_stats_map_lock", 
db);
+        BlockingTableStatsMeta record = new BlockingTableStatsMeta();
+        manager.replayUpdateTableStatsStatus(record);
+
+        ByteArrayOutputStream image = new ByteArrayOutputStream();
+        Thread dump = new Thread(() -> {
+            try {
+                manager.write(new DataOutputStream(image));
+            } catch (IOException e) {
+                throw new IllegalStateException(e);
+            }
+        });
+        dump.start();
+        Assertions.assertTrue(record.imageWriting.await(30, TimeUnit.SECONDS), 
"the image is not being written");
+
+        // The load of another table publishes its record from its own thread, 
exactly like the commit report
+        // of a cloud backend, which takes no table lock.
+        CountDownLatch loadReady = new CountDownLatch(1);
+        CountDownLatch insertNow = new CountDownLatch(1);
+        CountDownLatch inserted = new CountDownLatch(1);
+        AtomicReference<Throwable> failure = new AtomicReference<>();
+        Thread load = new Thread(() -> {
+            try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+                
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+                loadReady.countDown();
+                insertNow.await();
+                manager.replayUpdateRowsRecord(new UpdateRowsEvent(db.getId(),
+                        ImmutableMap.of(table.getId(), 1L)));
+                inserted.countDown();
+            } catch (Throwable t) {
+                failure.set(t);
+            }
+        });
+        load.start();
+        Assertions.assertTrue(loadReady.await(30, TimeUnit.SECONDS), "the load 
is not ready");
+        insertNow.countDown();
+        // The insertion has to wait for the monitor which the image holds, 
otherwise the record can be written
+        // although the number of records was already written.
+        boolean insertedWhileWriting = inserted.await(500, 
TimeUnit.MILLISECONDS);
+
+        record.resumeImage.countDown();
+        dump.join(30000);
+        load.join(30000);
+
+        Assertions.assertFalse(insertedWhileWriting, "the record was inserted 
while the image was written");
+        Assertions.assertNull(failure.get());
+        Assertions.assertFalse(dump.isAlive(), "the image was not written");
+        Assertions.assertFalse(load.isAlive());
+
+        DataInputStream in = new DataInputStream(new 
ByteArrayInputStream(image.toByteArray()));
+        AnalysisManager loaded = AnalysisManager.readFields(in);
+        // The reader consumes exactly the records which the image declares.
+        Assertions.assertEquals(0, in.available(), "the image contains more 
records than it declares");
+        Assertions.assertNotNull(loaded.findTableStatsStatus(0L));
+        Assertions.assertNull(loaded.findTableStatsStatus(table.getId()),
+                "the record which was created after the image was written is 
in the image");
+    }
+    /**
+     * A cloud load reports the rows of its transaction from the backend, and 
the report applies them and
+     * journals them afterwards. A whole table DROP STATS removes the record 
and journals the deletion, so
+     * without a common lock the deletion can be applied between the 
application of the report and its journal
+     * entry: a follower then replays "remove, then add" and keeps a record 
which the master no longer has.
+     */
+    @Test
+    public void testCloudRowUpdateIsSerializedWithTheTableLock() throws 
Exception {
+        AnalysisManager manager = new AnalysisManager();
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20003L, "test_cloud_row_update_lock");
+        catalog.unprotectCreateDb(db);
+        OlapTable table = mockOlapTableInReplica(30009L, "t_cloud_row_update", 
db);
+        Env env = Mockito.mock(Env.class);
+        EditLog editLog = Mockito.mock(EditLog.class);
+        ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
+        Mockito.when(table.tryWriteLockIfExist(Mockito.anyLong(), 
Mockito.any(TimeUnit.class)))
+                .thenAnswer(invocation -> 
lock.writeLock().tryLock(invocation.getArgument(0),
+                        invocation.getArgument(1)));
+        Mockito.doAnswer(invocation -> {
+            lock.writeLock().unlock();
+            return null;
+        }).when(table).writeUnlock();
+
+        CountDownLatch reportReady = new CountDownLatch(1);
+        CountDownLatch reported = new CountDownLatch(1);
+        AtomicReference<Throwable> failure = new AtomicReference<>();
+        Thread report = new Thread(() -> {
+            try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+                envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+                
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+                Mockito.when(env.getEditLog()).thenReturn(editLog);
+                Mockito.when(env.isMaster()).thenReturn(true);
+                reportReady.countDown();
+                manager.updateUpdatedRows(ImmutableMap.of(table.getId(), 5L), 
db.getId());
+                reported.countDown();
+            } catch (Throwable t) {
+                failure.set(t);
+            }
+        });
+
+        // The deletion holds the table write lock while it removes the record 
and journals it.
+        lock.writeLock().lock();
+        report.start();
+        Assertions.assertTrue(reportReady.await(30, TimeUnit.SECONDS), "the 
report is not ready");
+        boolean appliedWhileTheDeletionRan = reported.await(500, 
TimeUnit.MILLISECONDS);
+        lock.writeLock().unlock();
+        report.join(30000);
+
+        Assertions.assertFalse(appliedWhileTheDeletionRan, "the report was 
applied while the table was locked");
+        Assertions.assertNull(failure.get());
+        Assertions.assertFalse(report.isAlive());
+        Mockito.verify(env, Mockito.atLeast(1)).isMaster();
+        TableStatsMeta record = manager.findTableStatsStatus(table.getId());
+        Assertions.assertNotNull(record);
+        Assertions.assertEquals(5, record.updatedRows.get());
+        Mockito.verify(editLog, 
Mockito.times(1)).logUpdateRowsRecord(Mockito.any(UpdateRowsEvent.class));
+    }
+    /**
+     * A cloud stream load queues the report of the rows it loaded and can 
return before the frontend received
+     * it. A whole table TRUNCATE replaces the partitions and keeps the table 
id, so a report which arrives
+     * afterwards counts the rows the truncation removed: they must neither be 
applied to the record which the
+     * next load recreates nor be journaled.
+     */
+    @Test
+    public void testReportOfAPartitionRemovedByTruncateIsNotApplied() throws 
Exception {
+        AnalysisManager manager = new AnalysisManager();
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20004L, "test_stale_cloud_report");
+        catalog.unprotectCreateDb(db);
+        OlapTable table = mockOlapTableInReplica(30010L, "t_stale_report", db);
+        long replacedPartitionId = 40001L;
+        long partitionId = 40002L;
+        
Mockito.when(table.getPartition(partitionId)).thenReturn(Mockito.mock(Partition.class));
+        Mockito.when(table.tryWriteLockIfExist(Mockito.anyLong(), 
Mockito.any(TimeUnit.class))).thenReturn(true);
+        Env env = Mockito.mock(Env.class);
+        EditLog editLog = Mockito.mock(EditLog.class);
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+            
envMockedStatic.when(Env::getCurrentInternalCatalog).thenReturn(catalog);
+            Mockito.when(env.getEditLog()).thenReturn(editLog);
+            Mockito.when(env.isMaster()).thenReturn(true);
+
+            // The report of the 100 rows which the truncation removed arrives 
after the replacement partitions
+            // were created.
+            manager.updateUpdatedRows(ImmutableMap.of(table.getId(), 100L), 
db.getId(),
+                    ImmutableMap.of(table.getId(), 
ImmutableList.of(replacedPartitionId)));
+            Assertions.assertNull(manager.findTableStatsStatus(table.getId()));
+            Mockito.verify(editLog, 
Mockito.never()).logUpdateRowsRecord(Mockito.any(UpdateRowsEvent.class));
+
+            // The 3 rows which the next load reports belong to the current 
partition and recreate the record.
+            manager.updateUpdatedRows(ImmutableMap.of(table.getId(), 3L), 
db.getId(),
+                    ImmutableMap.of(table.getId(), 
ImmutableList.of(partitionId)));
+            TableStatsMeta record = 
manager.findTableStatsStatus(table.getId());
+            Assertions.assertNotNull(record);
+            Assertions.assertEquals(3, record.updatedRows.get());
+            
Mockito.verify(editLog).logUpdateRowsRecord(Mockito.any(UpdateRowsEvent.class));
+        }
+    }
+
+    @Test
+    public void testReportOfRemovedPartitionsIsRecognized() {
+        OlapTable table = Mockito.mock(OlapTable.class);
+        
Mockito.when(table.getPartition(1L)).thenReturn(Mockito.mock(Partition.class));
+
+        
Assertions.assertTrue(AnalysisManager.reportCountsCurrentPartitions(table, 
ImmutableList.of()));
+        
Assertions.assertTrue(AnalysisManager.reportCountsCurrentPartitions(table, 
ImmutableList.of(1L)));
+        
Assertions.assertFalse(AnalysisManager.reportCountsCurrentPartitions(table, 
ImmutableList.of(2L)));
+        // A report which counts rows of a removed partition is stale as a 
whole.
+        
Assertions.assertFalse(AnalysisManager.reportCountsCurrentPartitions(table, 
ImmutableList.of(1L, 2L)));
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/statistics/analysis/TableStatsMetaTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/statistics/analysis/TableStatsMetaTest.java
index 04208574f3d..a3ae026a679 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/statistics/analysis/TableStatsMetaTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/statistics/analysis/TableStatsMetaTest.java
@@ -17,17 +17,72 @@
 
 package org.apache.doris.statistics.analysis;
 
+import org.apache.doris.analysis.Expr;
+import org.apache.doris.catalog.AggregateType;
+import org.apache.doris.catalog.Column;
+import org.apache.doris.catalog.DatabaseIf;
+import org.apache.doris.catalog.KeysType;
+import org.apache.doris.catalog.MaterializedIndexMeta;
 import org.apache.doris.catalog.OlapTable;
+import org.apache.doris.catalog.PrimitiveType;
+import org.apache.doris.common.Pair;
+import org.apache.doris.datasource.CatalogIf;
 
 import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
 import java.util.HashSet;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 class TableStatsMetaTest {
 
+    private static final long BASE_INDEX_ID = 10001L;
+    private static final long ROLLUP_INDEX_ID = 10002L;
+    private static final String BASE_INDEX_NAME = "baseIndex";
+
+    private OlapTable mockOlapTable() {
+        OlapTable table = Mockito.mock(OlapTable.class);
+        Mockito.doReturn(Lists.newArrayList(BASE_INDEX_ID, 
ROLLUP_INDEX_ID)).when(table).getIndexIdList();
+        Mockito.doReturn(BASE_INDEX_ID).when(table).getBaseIndexId();
+        
Mockito.doReturn(BASE_INDEX_NAME).when(table).getIndexNameById(BASE_INDEX_ID);
+        DatabaseIf db = Mockito.mock(DatabaseIf.class);
+        CatalogIf catalog = Mockito.mock(CatalogIf.class);
+        Mockito.doReturn(db).when(table).getDatabase();
+        Mockito.doReturn(catalog).when(db).getCatalog();
+        return table;
+    }
+
+    // 100 rows of the base index had been loaded when the job was built.
+    private AnalysisInfo analyzeJob() {
+        AnalysisInfo job = new AnalysisInfoBuilder().setRowCount(100)
+                .setJobColumns(Sets.newHashSet(Pair.of(BASE_INDEX_NAME, 
"col1")))
+                .setUpdateRows(100)
+                .setTblUpdateTime(1000L)
+                .setJobType(AnalysisInfo.JobType.MANUAL)
+                .build();
+        job.addIndexRowCount(BASE_INDEX_ID, 100);
+        return job;
+    }
+
+    // The table has a rollup index besides the base index. The schema is the 
one a real index has: a key
+    // column, which carries no aggregation type at all, and a value column 
with the given one. A non null
+    // where clause makes it a filtered index.
+    private void mockRollupIndex(OlapTable table, long indexId, KeysType 
keysType, AggregateType valueType,
+            Expr whereClause) {
+        MaterializedIndexMeta indexMeta = 
Mockito.mock(MaterializedIndexMeta.class);
+        Mockito.doReturn(keysType).when(indexMeta).getKeysType();
+        Mockito.doReturn(whereClause).when(indexMeta).getWhereClause();
+        Column keyColumn = new Column("col1", PrimitiveType.INT);
+        keyColumn.setAggregationType(null, false);
+        Column valueColumn = new Column("col2", PrimitiveType.INT);
+        valueColumn.setAggregationType(valueType, false);
+        Mockito.doReturn(Lists.newArrayList(keyColumn, 
valueColumn)).when(indexMeta).getSchema();
+        Mockito.doReturn(indexMeta).when(table).getIndexMetaByIndexId(indexId);
+    }
+
     @Test
     void update() {
         OlapTable table = Mockito.mock(OlapTable.class);
@@ -58,4 +113,237 @@ class TableStatsMetaTest {
         Assertions.assertEquals(-1, meta.getRowCount(3));
         Assertions.assertEquals(-1, meta.getRowCount(4));
     }
+
+    @Test
+    void testRecordCreatedByALoadReportsTheLoadedRows() {
+        OlapTable table = mockOlapTable();
+        // The record a load creates for a table whose statistics were 
dropped, e.g. by TRUNCATE TABLE: no row
+        // count has ever been collected, so every loaded row is a delta row.
+        TableStatsMeta meta = new TableStatsMeta(table);
+        Assertions.assertTrue(meta.isColumnsStatsEmpty());
+        Assertions.assertEquals(0, meta.updatedTime);
+
+        meta.updatedRows.set(30);
+        Assertions.assertEquals(30, meta.getBaseIndexDeltaRowCount(table));
+        Assertions.assertEquals(0, meta.getRowCount(BASE_INDEX_ID));
+        // The planner reads this as the row count of the table while the 
backends have not reported the new
+        // tablets yet.
+        Assertions.assertEquals(30, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+
+        // The rows of the next loads add up.
+        meta.updatedRows.addAndGet(12);
+        Assertions.assertEquals(42, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+    }
+
+    @Test
+    void testDeltaRowCountKeptAfterColumnStatsDropped() {
+        OlapTable table = mockOlapTable();
+        TableStatsMeta meta = new TableStatsMeta();
+        meta.updatedRows.set(100);
+        meta.update(analyzeJob(), table);
+        // 50 rows were loaded after the analysis.
+        meta.updatedRows.set(150);
+        Assertions.assertEquals(50, meta.getBaseIndexDeltaRowCount(table));
+
+        // The baseline is recorded in the table stats, dropping all the 
column statistics of the table
+        // must not turn the 100 collected rows into delta rows again.
+        meta.removeColumn(BASE_INDEX_NAME, "col1");
+        Assertions.assertTrue(meta.isColumnsStatsEmpty());
+        Assertions.assertEquals(50, meta.getBaseIndexDeltaRowCount(table));
+    }
+
+    @Test
+    void testDeltaRowCountOfLegacyRecord() {
+        OlapTable table = mockOlapTable();
+        TableStatsMeta meta = new TableStatsMeta();
+        meta.updatedRows.set(150);
+        meta.update(analyzeJob(), table);
+        // A record written before the baseline was recorded in the table 
stats has none of its own, it is
+        // derived from the collected column statistics.
+        meta.clearUpdatedRowsBaseForTest();
+        Assertions.assertEquals(50, meta.getBaseIndexDeltaRowCount(table));
+
+        // Once they are all dropped there is no baseline left, no row is 
reported as a delta row.
+        meta.removeColumn(BASE_INDEX_NAME, "col1");
+        Assertions.assertTrue(meta.isColumnsStatsEmpty());
+        Assertions.assertEquals(0, meta.getBaseIndexDeltaRowCount(table));
+    }
+
+    @Test
+    void testDeltaRowCountBaselineNotAdvancedWithoutBaseIndexRowCount() {
+        OlapTable table = mockOlapTable();
+        TableStatsMeta meta = new TableStatsMeta();
+        meta.updatedRows.set(100);
+        meta.update(analyzeJob(), table);
+        meta.updatedRows.set(150);
+        Assertions.assertEquals(50, meta.getBaseIndexDeltaRowCount(table));
+
+        // An analyze job which collected the row count of another index only 
(a materialized view) does not
+        // refresh the collected row count of the base index, so it must not 
advance its baseline, otherwise
+        // the rows loaded in between are no longer reported as delta rows.
+        AnalysisInfo mvJob = new AnalysisInfoBuilder().setRowCount(100)
+                .setJobColumns(Sets.newHashSet(Pair.of("mvIndex", "col1")))
+                .setUpdateRows(150)
+                .build();
+        mvJob.addIndexRowCount(ROLLUP_INDEX_ID, 30);
+        meta.update(mvJob, table);
+        Assertions.assertEquals(50, meta.getBaseIndexDeltaRowCount(table));
+    }
+
+    @Test
+    void testDeltaRowCountOfStaleSnapshotDeliveredAfterTheRecordWasRecreated() 
{
+        OlapTable table = mockOlapTable();
+        // The record of a table whose statistics were dropped, with 5 rows 
loaded into the table since.
+        TableStatsMeta meta = new TableStatsMeta(table);
+        meta.updatedRows.set(5);
+
+        // An analyze job built before the statistics were dropped delivers 
its snapshot afterwards: it still
+        // describes the 100 rows it collected and the baseline it carries is 
the same 100 rows. The stale row
+        // count and the stale baseline cancel each other out, the table is 
still estimated with its 5 rows.
+        meta.update(analyzeJob(), table);
+        Assertions.assertEquals(100, meta.getRowCount(BASE_INDEX_ID));
+        Assertions.assertEquals(-95, meta.getBaseIndexDeltaRowCount(table));
+        Assertions.assertEquals(5, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+    }
+
+    @Test
+    void testDeltaRowCountOfInjectedRowCountAfterDropStats() {
+        OlapTable table = mockOlapTable();
+        // The statistics of the table were dropped and 5 rows are loaded into 
it.
+        TableStatsMeta meta = new TableStatsMeta(table);
+        meta.updatedRows.set(5);
+        // The user supplies the row count of the table as of now.
+        AnalysisInfo injected = new AnalysisInfoBuilder().setRowCount(5)
+                .setJobColumns(new HashSet<>()).setUserInject(true).build();
+        injected.addIndexRowCount(BASE_INDEX_ID, 5);
+        meta.update(injected, table);
+        Assertions.assertTrue(meta.userInjected);
+        // The row count supplied by the user is reported as long as it 
applies.
+        Assertions.assertEquals(0, meta.getBaseIndexDeltaRowCount(table));
+        Assertions.assertEquals(5, meta.getRowCount(BASE_INDEX_ID));
+
+        // Dropping the statistics of a column clears userInjected without 
clearing the row count. It and the
+        // baseline describe the same snapshot, so the loaded rows are not 
counted twice.
+        meta.userInjected = false;
+        Assertions.assertEquals(0, meta.getBaseIndexDeltaRowCount(table));
+        Assertions.assertEquals(5, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+    }
+
+    @Test
+    void testRowCountSnapshotIsCoherent() throws Exception {
+        OlapTable table = mockOlapTable();
+        TableStatsMeta meta = new TableStatsMeta(table);
+        // An analysis collected the 100 rows the table had, then 50 more rows 
were loaded.
+        meta.update(analyzeJob(), table);
+        meta.updatedRows.set(150);
+
+        AtomicBoolean incoherent = new AtomicBoolean(false);
+        AtomicBoolean stop = new AtomicBoolean(false);
+        Thread reader = new Thread(() -> {
+            while (!stop.get()) {
+                // Every state the writer goes through reports the 150 rows 
the table holds.
+                if (meta.getRowCountWithDeltaRows(table, BASE_INDEX_ID) != 
150) {
+                    incoherent.set(true);
+                }
+            }
+        });
+        reader.start();
+        // Only transitions which really happen are performed: an analysis 
collecting the 100 rows the table
+        // had, while 150 rows are loaded. A reader which pairs the collected 
row count of one state with the
+        // baseline of the other would compute 100 + (150 - 0) = 250.
+        for (int i = 0; i < 5000; i++) {
+            meta.update(analyzeJob(), table);
+            meta.updatedRows.set(150);
+        }
+        stop.set(true);
+        reader.join();
+        Assertions.assertFalse(incoherent.get(), "the reader saw a row count 
of neither state");
+    }
+
+    @Test
+    void testRollupKeepingOneRowPerBaseRowReportsTheLoadedRows() {
+        OlapTable table = mockOlapTable();
+        mockRollupIndex(table, ROLLUP_INDEX_ID, KeysType.DUP_KEYS, 
AggregateType.NONE, null);
+        TableStatsMeta meta = new TableStatsMeta(table);
+        meta.updatedRows.set(150);
+
+        Assertions.assertEquals(150, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+        Assertions.assertEquals(150, meta.getRowCountWithDeltaRows(table, 
ROLLUP_INDEX_ID));
+    }
+
+    @Test
+    void testAggregatingRollupDoesNotReportTheLoadedRows() {
+        OlapTable table = mockOlapTable();
+        mockRollupIndex(table, ROLLUP_INDEX_ID, KeysType.AGG_KEYS, 
AggregateType.SUM, null);
+        TableStatsMeta meta = new TableStatsMeta(table);
+        meta.updatedRows.set(150);
+
+        Assertions.assertEquals(150, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+        // The rollup aggregates the loaded rows, its row count is unknown 
until the backends report it, and
+        // the rows of the base index must not be reported as its row count.
+        Assertions.assertEquals(-1, meta.getRowCountWithDeltaRows(table, 
ROLLUP_INDEX_ID));
+    }
+
+    @Test
+    void testFilteredMaterializedIndexDoesNotReportTheLoadedRows() {
+        OlapTable table = mockOlapTable();
+        mockRollupIndex(table, ROLLUP_INDEX_ID, KeysType.DUP_KEYS, 
AggregateType.NONE,
+                Mockito.mock(Expr.class));
+        TableStatsMeta meta = new TableStatsMeta(table);
+        meta.updatedRows.set(150);
+
+        Assertions.assertEquals(150, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+        // A filtered index only holds the rows which satisfy its predicate, 
it must not be reported with the
+        // rows of the whole table.
+        Assertions.assertEquals(-1, meta.getRowCountWithDeltaRows(table, 
ROLLUP_INDEX_ID));
+    }
+
+    /**
+     * ALTER TABLE ADD ROLLUP keeps the record which the analysis of the table 
collected, so the new rollup
+     * has no entry of its own, and its tablets are not reported by the 
backends until the next report cycle.
+     * The rollup keeps one row per base row, so it is planned with the 
collected row count of the base index
+     * plus the rows loaded since then, instead of one row which the unknown 
row count is clamped to.
+     */
+    @Test
+    void testRollupAddedAfterTheRowCountWasCollectedReportsTheBaseRows() {
+        OlapTable table = mockOlapTable();
+        TableStatsMeta meta = new TableStatsMeta();
+        // The analysis collected the 100 rows which had been loaded, and the 
rollup does not exist yet.
+        meta.updatedRows.set(100);
+        meta.update(analyzeJob(), table);
+        // ALTER TABLE ADD ROLLUP r_dup(k1, v): the record is left as it is, 
r_dup has no entry.
+        mockRollupIndex(table, ROLLUP_INDEX_ID, KeysType.DUP_KEYS, 
AggregateType.NONE, null);
+        // 50 more rows are loaded before the backends report the tablets of 
the new index.
+        meta.updatedRows.addAndGet(50);
+
+        Assertions.assertEquals(150, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+        Assertions.assertEquals(150, meta.getRowCountWithDeltaRows(table, 
ROLLUP_INDEX_ID));
+    }
+
+    @Test
+    void 
testAggregatingRollupAddedAfterTheRowCountWasCollectedReportsNoRowCount() {
+        OlapTable table = mockOlapTable();
+        TableStatsMeta meta = new TableStatsMeta();
+        meta.update(analyzeJob(), table);
+        mockRollupIndex(table, ROLLUP_INDEX_ID, KeysType.AGG_KEYS, 
AggregateType.SUM, null);
+        meta.updatedRows.addAndGet(50);
+
+        // The rollup merges the rows of the base index, the collected row 
count of the base index must not be
+        // reported as its own.
+        Assertions.assertEquals(-1, meta.getRowCountWithDeltaRows(table, 
ROLLUP_INDEX_ID));
+    }
+
+    @Test
+    void testRollupWithoutCollectedRowCountOfTheBaseIndexStaysUnknown() {
+        OlapTable table = mockOlapTable();
+        mockRollupIndex(table, ROLLUP_INDEX_ID, KeysType.DUP_KEYS, 
AggregateType.NONE, null);
+        // A record which has never collected a row count, e.g. one written 
before updatedRowsBase existed.
+        TableStatsMeta meta = new TableStatsMeta();
+        meta.updatedRows.set(50);
+
+        // The loaded rows are a delta relative to a collection which does not 
exist, neither the rollup nor
+        // the base index can be reported with a row count.
+        Assertions.assertEquals(-1, meta.getRowCountWithDeltaRows(table, 
ROLLUP_INDEX_ID));
+        Assertions.assertEquals(-1, meta.getRowCountWithDeltaRows(table, 
BASE_INDEX_ID));
+    }
 }
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/statistics/repository/StatisticsCleanerTest.java
 
b/fe/fe-core/src/test/java/org/apache/doris/statistics/repository/StatisticsCleanerTest.java
index 1823c09a5f3..5da3db1c0f6 100644
--- 
a/fe/fe-core/src/test/java/org/apache/doris/statistics/repository/StatisticsCleanerTest.java
+++ 
b/fe/fe-core/src/test/java/org/apache/doris/statistics/repository/StatisticsCleanerTest.java
@@ -17,11 +17,18 @@
 
 package org.apache.doris.statistics.repository;
 
+import org.apache.doris.catalog.Database;
+import org.apache.doris.catalog.Env;
 import org.apache.doris.catalog.OlapTable;
 import org.apache.doris.common.Config;
+import org.apache.doris.datasource.InternalCatalog;
+import org.apache.doris.persist.EditLog;
+import org.apache.doris.persist.TableStatsDeletionLog;
 import org.apache.doris.statistics.StatisticConstants;
+import org.apache.doris.statistics.analysis.AnalysisManager;
 import org.apache.doris.statistics.repository.StatisticsCleaner.ExpiredStats;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -29,6 +36,9 @@ import org.mockito.ArgumentMatchers;
 import org.mockito.MockedStatic;
 import org.mockito.Mockito;
 
+import java.util.ArrayList;
+import java.util.List;
+
 class StatisticsCleanerTest {
 
     @Test
@@ -51,4 +61,78 @@ class StatisticsCleanerTest {
             Assertions.assertEquals(expiredStats, 
StatisticConstants.FETCH_LIMIT);
         }
     }
+
+    /**
+     * The record of a table which is in the recycle bin is removed under the 
table write lock, which the
+     * recovery of that table and the load that recreates the record take as 
well. The deletion therefore
+     * cannot be journaled after such a load recreated the record, which would 
leave the master with a record
+     * that its followers removed.
+     */
+    @Test
+    void testRecycledTableStatsAreRemovedUnderTheTableLock() {
+        StatisticsCleaner cleaner = new StatisticsCleaner();
+        AnalysisManager analysisManager = Mockito.mock(AnalysisManager.class);
+        InternalCatalog catalog = new InternalCatalog();
+        OlapTable table = Mockito.mock(OlapTable.class);
+        Mockito.when(table.getId()).thenReturn(40001L);
+        Env env = Mockito.mock(Env.class);
+        EditLog editLog = Mockito.mock(EditLog.class);
+        List<String> calls = new ArrayList<>();
+        Mockito.doAnswer(invocation -> {
+            calls.add("writeLock");
+            return null;
+        }).when(table).writeLock();
+        Mockito.doAnswer(invocation -> {
+            calls.add("removeTableStats");
+            return null;
+        }).when(analysisManager).removeTableStats(40001L);
+        Mockito.doAnswer(invocation -> {
+            calls.add("logDeleteTableStats");
+            return null;
+        
}).when(editLog).logDeleteTableStats(Mockito.any(TableStatsDeletionLog.class));
+        Mockito.doAnswer(invocation -> {
+            calls.add("writeUnlock");
+            return null;
+        }).when(table).writeUnlock();
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+            Mockito.when(env.getEditLog()).thenReturn(editLog);
+
+            cleaner.removeRecycledTableStats(table, analysisManager, catalog);
+
+            Assertions.assertEquals(ImmutableList.of("writeLock", 
"removeTableStats", "logDeleteTableStats",
+                    "writeUnlock"), calls);
+        }
+    }
+
+    /**
+     * A table which was recovered from the recycle bin after its record was 
found orphaned keeps its
+     * statistics: the catalog is checked again under the table lock, so the 
record is not removed just
+     * because it was orphaned when the cleaner looked at it.
+     */
+    @Test
+    void testTableRecoveredBeforeTheRemovalKeepsItsStats() {
+        StatisticsCleaner cleaner = new StatisticsCleaner();
+        AnalysisManager analysisManager = Mockito.mock(AnalysisManager.class);
+        InternalCatalog catalog = new InternalCatalog();
+        Database db = new Database(20001L, "test_recovered_table");
+        catalog.unprotectCreateDb(db);
+        OlapTable table = Mockito.mock(OlapTable.class);
+        Mockito.when(table.getId()).thenReturn(40002L);
+        Mockito.when(table.getName()).thenReturn("t_recovered");
+        db.registerTable(table);
+        Env env = Mockito.mock(Env.class);
+        EditLog editLog = Mockito.mock(EditLog.class);
+
+        try (MockedStatic<Env> envMockedStatic = 
Mockito.mockStatic(Env.class)) {
+            envMockedStatic.when(Env::getCurrentEnv).thenReturn(env);
+            Mockito.when(env.getEditLog()).thenReturn(editLog);
+
+            cleaner.removeRecycledTableStats(table, analysisManager, catalog);
+
+            Mockito.verify(analysisManager, 
Mockito.never()).removeTableStats(Mockito.anyLong());
+            Mockito.verify(editLog, 
Mockito.never()).logDeleteTableStats(Mockito.any(TableStatsDeletionLog.class));
+        }
+    }
 }
diff --git a/regression-test/data/query_p0/eager_agg/scalar_agg_pushdown.out 
b/regression-test/data/query_p0/eager_agg/scalar_agg_pushdown.out
index 8fa4341a049..975f6894552 100644
--- a/regression-test/data/query_p0/eager_agg/scalar_agg_pushdown.out
+++ b/regression-test/data/query_p0/eager_agg/scalar_agg_pushdown.out
@@ -14,8 +14,8 @@ PhysicalResultSink
 --------PhysicalStorageLayerAggregate[t2]
 
 Hint log:
-Used: leading(ta tb )
-UnUsed:
+Used:
+UnUsed: leading(ta broadcast tb)
 SyntaxError:
 
 -- !const_group_key_exe --
@@ -32,8 +32,8 @@ PhysicalResultSink
 --------PhysicalStorageLayerAggregate[t2]
 
 Hint log:
-Used: leading(ta broadcast tb )
-UnUsed:
+Used:
+UnUsed: leading(ta broadcast tb)
 SyntaxError:
 
 -- !pushdown_preserved_exe --
diff --git a/regression-test/data/query_p0/hint/multi_leading.out 
b/regression-test/data/query_p0/hint/multi_leading.out
index e1446884fe2..742733456c0 100644
--- a/regression-test/data/query_p0/hint/multi_leading.out
+++ b/regression-test/data/query_p0/hint/multi_leading.out
@@ -302,7 +302,7 @@ PhysicalCteAnchor ( cteId=CTEId#0 )
 ----PhysicalOlapScan[t1]
 --PhysicalResultSink
 ----PhysicalDistribute[DistributionSpecGather]
-------NestedLoopJoin[INNER_JOIN](cast(sum(c11) as DOUBLE) > avg(t1.c11) / 20.0)
+------NestedLoopJoin[INNER_JOIN](cast(sum(c11) as DOUBLE) > (avg(c11) / 20.0))
 --------hashAgg[GLOBAL]
 ----------PhysicalDistribute[DistributionSpecHash]
 ------------PhysicalCteConsumer ( cteId=CTEId#0 )
diff --git a/regression-test/data/query_p0/runtimefilterV2/rfv2.out 
b/regression-test/data/query_p0/runtimefilterV2/rfv2.out
index b4eb3a96667..3cb34e5526f 100644
--- a/regression-test/data/query_p0/runtimefilterV2/rfv2.out
+++ b/regression-test/data/query_p0/runtimefilterV2/rfv2.out
@@ -2,35 +2,35 @@
 -- !1 --
 PhysicalResultSink
 --PhysicalDistribute[DistributionSpecGather]
-----PhysicalIntersect build RFs:RF0 a1->b1 RF1 a1->b1 RF2 a1->c1 RF3 a1->c1 
+----PhysicalIntersect build RFs:RF0 b1->a1 RF1 b1->a1 RF2 b1->c1 RF3 b1->c1 
 ------PhysicalDistribute[DistributionSpecHash]
---------PhysicalOlapScan[a]
+--------PhysicalOlapScan[b]
 ------PhysicalDistribute[DistributionSpecHash]
---------PhysicalOlapScan[b] apply RFs: RF0 RF1
+--------PhysicalOlapScan[a] apply RFs: RF0 RF1
 ------PhysicalDistribute[DistributionSpecHash]
 --------PhysicalOlapScan[c] apply RFs: RF2 RF3
 
 -- !2 --
 PhysicalResultSink
 --PhysicalDistribute[DistributionSpecGather]
-----PhysicalIntersect build RFs:RF0 x->cast(b1 as BIGINT) RF1 x->cast(b1 as 
BIGINT) 
+----PhysicalIntersect build RFs:RF0 b1->(cast(a1 as BIGINT) + 1) RF1 
b1->(cast(a1 as BIGINT) + 1) 
 ------PhysicalDistribute[DistributionSpecHash]
 --------PhysicalProject
-----------PhysicalOlapScan[a]
+----------PhysicalOlapScan[b]
 ------PhysicalDistribute[DistributionSpecHash]
 --------PhysicalProject
-----------PhysicalOlapScan[b] apply RFs: RF0 RF1
+----------PhysicalOlapScan[a] apply RFs: RF0 RF1
 
 -- !3 --
 PhysicalResultSink
 --PhysicalDistribute[DistributionSpecGather]
-----PhysicalIntersect build RFs:RF0 x->cast(b1 as BIGINT) RF1 x->cast(b1 as 
BIGINT) RF2 x->cast(c1 as BIGINT) RF3 x->cast(c1 as BIGINT) 
+----PhysicalIntersect build RFs:RF0 b1->(cast(a1 as BIGINT) + 1) RF1 
b1->(cast(a1 as BIGINT) + 1) RF2 b1->cast(c1 as BIGINT) RF3 b1->cast(c1 as 
BIGINT) 
 ------PhysicalDistribute[DistributionSpecHash]
 --------PhysicalProject
-----------PhysicalOlapScan[a]
+----------PhysicalOlapScan[b]
 ------PhysicalDistribute[DistributionSpecHash]
 --------PhysicalProject
-----------PhysicalOlapScan[b] apply RFs: RF0 RF1
+----------PhysicalOlapScan[a] apply RFs: RF0 RF1
 ------PhysicalDistribute[DistributionSpecHash]
 --------PhysicalProject
 ----------PhysicalOlapScan[c] apply RFs: RF2 RF3
diff --git 
a/regression-test/data/shape_check/tpcds_sf100/noStatsRfPrune/query24.out 
b/regression-test/data/shape_check/tpcds_sf100/noStatsRfPrune/query24.out
index c3708d1c485..125c9e8a136 100644
--- a/regression-test/data/shape_check/tpcds_sf100/noStatsRfPrune/query24.out
+++ b/regression-test/data/shape_check/tpcds_sf100/noStatsRfPrune/query24.out
@@ -7,28 +7,28 @@ PhysicalCteAnchor ( cteId=CTEId#0 )
 --------PhysicalDistribute[DistributionSpecHash]
 ----------hashAgg[LOCAL]
 ------------PhysicalProject
---------------hashJoin[INNER_JOIN bucketShuffle] 
hashCondition=((store_sales.ss_item_sk = store_returns.sr_item_sk) and 
(store_sales.ss_ticket_number = store_returns.sr_ticket_number)) 
otherCondition=()
+--------------hashJoin[INNER_JOIN broadcast] 
hashCondition=((customer.c_current_addr_sk = customer_address.ca_address_sk) 
and (store.s_zip = customer_address.ca_zip)) otherCondition=(( not 
(customer.c_birth_country = upper(customer_address.ca_country))))
 ----------------PhysicalProject
-------------------hashJoin[INNER_JOIN broadcast] 
hashCondition=((store_sales.ss_item_sk = item.i_item_sk)) otherCondition=()
+------------------hashJoin[INNER_JOIN broadcast] 
hashCondition=((store_sales.ss_customer_sk = customer.c_customer_sk)) 
otherCondition=()
 --------------------PhysicalProject
-----------------------hashJoin[INNER_JOIN broadcast] 
hashCondition=((store_sales.ss_customer_sk = customer.c_customer_sk) and 
(store_sales.ss_store_sk = store.s_store_sk)) otherCondition=() build RFs:RF2 
c_customer_sk->ss_customer_sk;RF3 s_store_sk->ss_store_sk
+----------------------hashJoin[INNER_JOIN broadcast] 
hashCondition=((store_sales.ss_item_sk = item.i_item_sk)) otherCondition=()
 ------------------------PhysicalProject
---------------------------PhysicalOlapScan[store_sales] apply RFs: RF2 RF3
-------------------------PhysicalProject
---------------------------hashJoin[INNER_JOIN shuffle] 
hashCondition=((customer.c_current_addr_sk = customer_address.ca_address_sk)) 
otherCondition=(( not (customer.c_birth_country = 
upper(customer_address.ca_country)))) build RFs:RF1 
ca_address_sk->c_current_addr_sk
-----------------------------PhysicalProject
-------------------------------PhysicalOlapScan[customer] apply RFs: RF1
+--------------------------hashJoin[INNER_JOIN broadcast] 
hashCondition=((store_sales.ss_store_sk = store.s_store_sk)) otherCondition=() 
build RFs:RF2 s_store_sk->ss_store_sk
 ----------------------------PhysicalProject
-------------------------------hashJoin[INNER_JOIN broadcast] 
hashCondition=((store.s_zip = customer_address.ca_zip)) otherCondition=() build 
RFs:RF0 s_zip->ca_zip
+------------------------------hashJoin[INNER_JOIN bucketShuffle] 
hashCondition=((store_sales.ss_item_sk = store_returns.sr_item_sk) and 
(store_sales.ss_ticket_number = store_returns.sr_ticket_number)) 
otherCondition=()
 --------------------------------PhysicalProject
-----------------------------------PhysicalOlapScan[customer_address] apply 
RFs: RF0
+----------------------------------PhysicalOlapScan[store_sales] apply RFs: RF2
 --------------------------------PhysicalProject
-----------------------------------filter((store.s_market_id = 8))
-------------------------------------PhysicalOlapScan[store]
+----------------------------------PhysicalOlapScan[store_returns]
+----------------------------PhysicalProject
+------------------------------filter((store.s_market_id = 8))
+--------------------------------PhysicalOlapScan[store]
+------------------------PhysicalProject
+--------------------------PhysicalOlapScan[item]
 --------------------PhysicalProject
-----------------------PhysicalOlapScan[item]
+----------------------PhysicalOlapScan[customer]
 ----------------PhysicalProject
-------------------PhysicalOlapScan[store_returns]
+------------------PhysicalOlapScan[customer_address]
 --PhysicalResultSink
 ----PhysicalQuickSort[MERGE_SORT]
 ------PhysicalDistribute[DistributionSpecGather]
diff --git a/regression-test/suites/mtmv_p0/ivm/test_ivm_explain_refresh.groovy 
b/regression-test/suites/mtmv_p0/ivm/test_ivm_explain_refresh.groovy
index bf2136a6456..3a272189c79 100644
--- a/regression-test/suites/mtmv_p0/ivm/test_ivm_explain_refresh.groovy
+++ b/regression-test/suites/mtmv_p0/ivm/test_ivm_explain_refresh.groovy
@@ -65,10 +65,12 @@ suite("test_ivm_explain_refresh") {
     // CREATE-time snapshot of affectQueryResultInPlan session variables 
(LogicalPlanBuilder:
     // CreateMTMVInfo -> getAffectQueryResultInPlanVariables), not from the 
session running the
     // EXPLAIN. The variables that could pin this plan (join distribution, 
runtime filters,
-    // ignore_shape_nodes) carry no affectQueryResultInPlan flag, so `set` 
cannot reach them —
-    // verified empirically (regenerating with ignore_shape_nodes set produced 
identical output).
-    // The normalization in explainIvmPlanWithoutStreamId is therefore the 
only reliable way to
-    // keep these shape expectations stable across environments.
+    // ignore_shape_nodes, disable_join_reorder) carry no 
affectQueryResultInPlan flag, so `set`
+    // cannot reach them — verified empirically (regenerating with 
ignore_shape_nodes set
+    // produced identical output). The normalization in 
explainIvmPlanWithoutStreamId and the
+    // statistics the two tables publish before the plans are explained (see 
below) are what
+    // keep these shape expectations stable.
+
     sql """drop materialized view if exists test_ivm_explain_refresh_mv;"""
     sql """drop table if exists test_ivm_explain_refresh_t1;"""
     sql """drop table if exists test_ivm_explain_refresh_t2;"""
@@ -126,6 +128,28 @@ suite("test_ivm_explain_refresh") {
 
     sql "INSERT INTO test_ivm_explain_refresh_t1 VALUES (1, 11), (4, 40);"
 
+    // Pin the statistics of the two tables before the plans below are 
explained. The shape
+    // of the refresh plan follows them: while a table has no statistics, it 
is planned at
+    // the rows its load reported, and as soon as the Automatic Analyzer 
collected them the
+    // outer join of the pending stream plan is mirrored. The analyzer runs at 
some point
+    // after the load and cannot be reached from this session (see the note 
above), so the
+    // tables publish their exact statistics instead: t1 holds the physical 
rows (1,10),
+    // (2,20), (1,11) and (4,40), t2 holds (1,100) and (3,300).
+    multi_sql """
+        alter table test_ivm_explain_refresh_t1 modify column k1
+            set stats ('row_count'='4', 'ndv'='3', 'data_size'='12', 
'avg_size'='3', 'max_size'='4',
+                       'num_nulls'='0', 'min_value'='1', 'max_value'='4');
+        alter table test_ivm_explain_refresh_t1 modify column v1
+            set stats ('row_count'='4', 'ndv'='4', 'data_size'='16', 
'avg_size'='4', 'max_size'='4',
+                       'num_nulls'='0', 'min_value'='10', 'max_value'='40');
+        alter table test_ivm_explain_refresh_t2 modify column k1
+            set stats ('row_count'='2', 'ndv'='2', 'data_size'='8', 
'avg_size'='4', 'max_size'='4',
+                       'num_nulls'='0', 'min_value'='1', 'max_value'='3');
+        alter table test_ivm_explain_refresh_t2 modify column v2
+            set stats ('row_count'='2', 'ndv'='2', 'data_size'='8', 
'avg_size'='4', 'max_size'='4',
+                       'num_nulls'='0', 'min_value'='100', 'max_value'='300');
+    """
+
     explainIvmPlanWithoutStreamId("all_streams_shape_plan", """
         EXPLAIN SHAPE PLAN REFRESH MATERIALIZED VIEW 
test_ivm_explain_refresh_mv INCREMENTAL WITH ALL STREAMS
     """)
diff --git 
a/regression-test/suites/nereids_rules_p0/subquery_to_window_function/correlated_scalar_subquery_to_window_function.groovy
 
b/regression-test/suites/nereids_rules_p0/subquery_to_window_function/correlated_scalar_subquery_to_window_function.groovy
index 08d155811a3..d71e91f8146 100644
--- 
a/regression-test/suites/nereids_rules_p0/subquery_to_window_function/correlated_scalar_subquery_to_window_function.groovy
+++ 
b/regression-test/suites/nereids_rules_p0/subquery_to_window_function/correlated_scalar_subquery_to_window_function.groovy
@@ -34,6 +34,10 @@ suite("correlated_scalar_subquery_to_window_function") {
         set push_topn_to_agg = true;
         set topn_opt_limit_threshold=1024;
     """
+    // The join order of the shape plans below must not follow the row counts 
the
+    // planner estimates, otherwise a change of the collected table statistics
+    // rewrites the expectations without any rule of this suite changing.
+    sql "set disable_join_reorder=true"
 
     sql "DROP TABLE IF EXISTS fact FORCE"
     sql "DROP TABLE IF EXISTS dim FORCE"
diff --git 
a/regression-test/suites/query_p0/eager_agg/scalar_agg_pushdown.groovy 
b/regression-test/suites/query_p0/eager_agg/scalar_agg_pushdown.groovy
index c9dce6c678a..c9c8e9bdded 100644
--- a/regression-test/suites/query_p0/eager_agg/scalar_agg_pushdown.groovy
+++ b/regression-test/suites/query_p0/eager_agg/scalar_agg_pushdown.groovy
@@ -20,6 +20,9 @@ suite("scalar_agg_pushdown") {
     sql "set disable_nereids_rules='SALT_JOIN';"
     sql "set runtime_filter_mode=OFF;"
     sql 'set ignore_shape_nodes="PhysicalProject, PhysicalDistribute";'
+    // Keep the join order of the shape plans below independent of the 
estimated
+    // row counts, so a change of the collected table statistics cannot 
rewrite them.
+    sql "set disable_join_reorder=true"
 
     // Test: scalar aggregation (no GROUP BY) must not be pushed below a join,
     // because scalar agg on empty input returns 1 NULL row (SQL standard),
diff --git 
a/regression-test/suites/shape_check/tpcds_sf100/noStatsRfPrune/query24.groovy 
b/regression-test/suites/shape_check/tpcds_sf100/noStatsRfPrune/query24.groovy
index 881ba1887ca..33dcb7c4731 100644
--- 
a/regression-test/suites/shape_check/tpcds_sf100/noStatsRfPrune/query24.groovy
+++ 
b/regression-test/suites/shape_check/tpcds_sf100/noStatsRfPrune/query24.groovy
@@ -32,6 +32,9 @@ sql 'set enable_runtime_filter_prune=true'
     sql 'set parallel_pipeline_task_num=8'
     sql 'set forbid_unknown_col_stats=false'
     sql 'set enable_stats=false'
+    // Keep the join order independent of the estimated row counts, so that a 
change of
+    // the collected table statistics cannot rewrite the shape expectation 
below.
+    sql 'set disable_join_reorder=true'
     sql "set runtime_filter_type=8"
     sql 'set broadcast_row_count_limit = 30000000'
     sql 'set enable_nereids_timeout = false'
diff --git 
a/regression-test/suites/statistics/test_row_count_after_truncate.groovy 
b/regression-test/suites/statistics/test_row_count_after_truncate.groovy
new file mode 100644
index 00000000000..22f0e07027b
--- /dev/null
+++ b/regression-test/suites/statistics/test_row_count_after_truncate.groovy
@@ -0,0 +1,131 @@
+// 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.
+
+suite("test_row_count_after_truncate") {
+    sql """drop database if exists test_row_count_after_truncate"""
+    sql """create database test_row_count_after_truncate"""
+    sql """use test_row_count_after_truncate"""
+
+    sql """CREATE TABLE test_row_count_after_truncate_tbl (
+            k INT NOT NULL,
+            v INT NULL
+        ) ENGINE = OLAP
+        DUPLICATE KEY(k)
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1"
+        )
+    """
+    sql """INSERT INTO test_row_count_after_truncate_tbl VALUES (1, 1), (2, 
2), (3, 3), (4, 4), (5, 5)"""
+
+    // TRUNCATE TABLE removes all the data and the statistics record of the 
table. The backends report the row
+    // count of the new tablets with a delay and the rows loaded right after 
the truncation are not visible in
+    // that report either, so the row count of the table can only come from 
the statistics record, which the
+    // load itself recreates when it becomes visible.
+    sql """TRUNCATE TABLE test_row_count_after_truncate_tbl"""
+    sql """INSERT INTO test_row_count_after_truncate_tbl VALUES (1, 1), (2, 
2), (3, 3)"""
+
+    // The scan must report the rows of the table instead of -1 (which is 
planned as 1 row), i.e. the record
+    // the load recreated has accumulated the rows it loaded.
+    explain {
+        sql """SELECT * FROM test_row_count_after_truncate_tbl"""
+        contains "cardinality=3, "
+    }
+
+    // The same from the stats record itself. The backend reports the new 
tablets with a delay, so the record
+    // is the only place the rows loaded so far are visible.
+    String updatedRows = ""
+    for (int i = 0; i < 30; i++) {
+        def stats = sql """SHOW TABLE STATS 
test_row_count_after_truncate_tbl"""
+        updatedRows = stats[0][0]
+        if (updatedRows == "3") {
+            break
+        }
+        Thread.sleep(1000)
+    }
+    assertEquals("3", updatedRows)
+
+    // A scan which selects an aggregate rollup index must report the row 
count of that index. The rows the
+    // load reports are only known for the base index, they must not be added 
to an index which aggregates
+    // them, otherwise the rollup is estimated with the rows of the whole 
table.
+    sql """DROP TABLE IF EXISTS test_row_count_after_truncate_aggr"""
+    sql """CREATE TABLE test_row_count_after_truncate_aggr (
+            k1 INT NOT NULL,
+            k2 INT NOT NULL,
+            v INT SUM NOT NULL
+        ) ENGINE = OLAP
+        AGGREGATE KEY(k1, k2)
+        DISTRIBUTED BY HASH(k1) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1"
+        )
+    """
+    sql """ALTER TABLE test_row_count_after_truncate_aggr ADD ROLLUP r1 (k1, 
v)"""
+    waitForSchemaChangeDone {
+        sql """SHOW ALTER TABLE ROLLUP WHERE 
TableName='test_row_count_after_truncate_aggr' ORDER BY CreateTime DESC LIMIT 
1"""
+        time 600
+    }
+    sql """INSERT INTO test_row_count_after_truncate_aggr SELECT 1, number, 1 
FROM numbers("number" = "100")"""
+    sql """TRUNCATE TABLE test_row_count_after_truncate_aggr"""
+    sql """INSERT INTO test_row_count_after_truncate_aggr SELECT 1, number, 1 
FROM numbers("number" = "100")"""
+
+    // 100 rows are loaded into the base index, the rollup aggregates them 
into a single row. Whether the
+    // backends have reported the new tablets or not, the rollup scan reports 
its own row count and the
+    // base index scan reports the 100 loaded rows.
+    explain {
+        sql """SELECT k1, sum(v) FROM test_row_count_after_truncate_aggr GROUP 
BY k1"""
+        contains "(r1)"
+        contains "cardinality=1, "
+    }
+    explain {
+        sql """SELECT k2 FROM test_row_count_after_truncate_aggr"""
+        contains "cardinality=100, "
+    }
+
+    // A rollup of a duplicate key table which keeps one row per base row has 
the same row count as the base
+    // index, so the rows the load reports are its rows as well. The rollup 
drops a key column
+    // (k2), which is what makes it a rollup the analyzer accepts: a 
projection whose columns are a prefix of
+    // the base table's columns is rejected as useless for a duplicate key 
table.
+    sql """DROP TABLE IF EXISTS test_row_count_after_truncate_dup_rollup"""
+    sql """CREATE TABLE test_row_count_after_truncate_dup_rollup (
+            k1 INT NOT NULL,
+            k2 INT NOT NULL,
+            v INT NULL
+        ) ENGINE = OLAP
+        DUPLICATE KEY(k1, k2)
+        DISTRIBUTED BY HASH(k1) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1"
+        )
+    """
+    sql """ALTER TABLE test_row_count_after_truncate_dup_rollup ADD ROLLUP 
r_dup (k1, v)"""
+    waitForSchemaChangeDone {
+        sql """SHOW ALTER TABLE ROLLUP WHERE 
TableName='test_row_count_after_truncate_dup_rollup' ORDER BY CreateTime DESC 
LIMIT 1"""
+        time 600
+    }
+    sql """INSERT INTO test_row_count_after_truncate_dup_rollup VALUES (1, 1, 
1), (2, 2, 2), (3, 3, 3)"""
+    sql """TRUNCATE TABLE test_row_count_after_truncate_dup_rollup"""
+    sql """INSERT INTO test_row_count_after_truncate_dup_rollup VALUES (1, 1, 
1), (2, 2, 2), (3, 3, 3)"""
+
+    // The scan of the rollup reports the rows loaded into the table: it keeps 
one row per base row, so
+    // neither its own -1 (the backends have not reported the new rollup 
tablets yet) nor 1 is right.
+    explain {
+        sql """SELECT k1, v FROM test_row_count_after_truncate_dup_rollup 
INDEX r_dup"""
+        contains "(r_dup)"
+        contains "cardinality=3, "
+    }
+}


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

Reply via email to