This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new 61d51d9dcd3 branch-4.2: [fix](statistics) Create the table stats
record when a load finishes #69028 (#70139)
61d51d9dcd3 is described below
commit 61d51d9dcd3ad816633d3e8bcff653def4298af4
Author: morrySnow <[email protected]>
AuthorDate: Thu Nov 5 09:26:01 2026 +0800
branch-4.2: [fix](statistics) Create the table stats record when a load
finishes #69028 (#70139)
picked from master #69028
Co-authored-by: minghong <[email protected]>
---
.../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 +-
.../apache/doris/statistics/AnalysisManager.java | 251 +++++++++---
.../doris/statistics/StatisticsAutoCollector.java | 4 +-
.../apache/doris/statistics/StatisticsCleaner.java | 38 ++
.../statistics/StatisticsMetricCollector.java | 6 +-
.../apache/doris/statistics/TableStatsMeta.java | 150 ++++++-
.../apache/doris/statistics/UpdateRowsEvent.java | 11 +-
.../doris/alter/SchemaChangeJobV2StatsTest.java | 59 +++
.../doris/nereids/stats/StatsCalculatorTest.java | 45 +++
.../doris/statistics/AnalysisManagerTest.java | 436 +++++++++++++++++++++
.../doris/statistics/StatisticsCleanerTest.java | 83 ++++
.../doris/statistics/TableStatsMetaTest.java | 289 ++++++++++++++
.../nereids_p0/eager_agg/scalar_agg_pushdown.out | 8 +-
.../data/query_p0/runtimefilterV2/rfv2.out | 18 +-
.../tpcds_sf100/noStatsRfPrune/query24.out | 20 +-
.../eager_agg/scalar_agg_pushdown.groovy | 3 +
...lated_scalar_subquery_to_window_function.groovy | 4 +
.../tpcds_sf100/noStatsRfPrune/query24.groovy | 3 +
.../test_row_count_after_truncate.groovy | 131 +++++++
23 files changed, 1560 insertions(+), 95 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 65231191f25..57b24fae28f 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
@@ -55,7 +55,6 @@ import
org.apache.doris.nereids.rules.expression.check.CheckCast;
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.AnalysisManager;
import org.apache.doris.task.AgentBatchTask;
import org.apache.doris.task.AgentTask;
import org.apache.doris.task.AgentTaskExecutor;
@@ -69,6 +68,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;
@@ -714,18 +714,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 255f279be5a..79a91087c66 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
@@ -273,6 +273,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 9f8a133491e..b99382f82e0 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
@@ -523,6 +523,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()) {
@@ -532,7 +541,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 e497f6ba6c2..241b48d3437 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
@@ -155,6 +155,7 @@ import
org.apache.doris.statistics.StatisticsCache.OlapTableStatistics;
import org.apache.doris.statistics.TableStatsMeta;
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;
@@ -409,14 +410,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;
}
@@ -499,7 +507,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 f9bc380bb51..05ddf6fb663 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/AnalysisManager.java
b/fe/fe-core/src/main/java/org/apache/doris/statistics/AnalysisManager.java
index 1de58345dc1..3e6836b4400 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/AnalysisManager.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/AnalysisManager.java
@@ -39,6 +39,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;
@@ -737,8 +738,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.
@@ -1236,6 +1236,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 {
@@ -1258,17 +1305,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
@@ -1315,7 +1438,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());
}
@@ -1326,52 +1449,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();
@@ -1447,6 +1570,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/StatisticsAutoCollector.java
b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsAutoCollector.java
index 0dbe97f1ba1..3285ca783ee 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsAutoCollector.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/statistics/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.iceberg.IcebergExternalTable;
-import org.apache.doris.persist.TableStatsDeletionLog;
import org.apache.doris.statistics.AnalysisInfo.AnalysisMethod;
import org.apache.doris.statistics.AnalysisInfo.JobType;
import org.apache.doris.statistics.AnalysisInfo.ScheduleType;
@@ -200,8 +199,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/StatisticsCleaner.java
b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCleaner.java
index 0ff2963683b..8482f75d78a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCleaner.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsCleaner.java
@@ -31,6 +31,7 @@ import org.apache.doris.datasource.InternalCatalog;
import org.apache.doris.persist.TableStatsDeletionLog;
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;
@@ -137,6 +138,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) {
@@ -145,6 +153,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/main/java/org/apache/doris/statistics/StatisticsMetricCollector.java
b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsMetricCollector.java
index 838ba9736ce..8263c4ec71f 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsMetricCollector.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/statistics/StatisticsMetricCollector.java
@@ -85,7 +85,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/TableStatsMeta.java
b/fe/fe-core/src/main/java/org/apache/doris/statistics/TableStatsMeta.java
index 0b77aba91e3..66e8958973d 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/TableStatsMeta.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/TableStatsMeta.java
@@ -17,7 +17,10 @@
package org.apache.doris.statistics;
+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/UpdateRowsEvent.java
b/fe/fe-core/src/main/java/org/apache/doris/statistics/UpdateRowsEvent.java
index c9a0b9d820d..9f5f2615b23 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/statistics/UpdateRowsEvent.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/statistics/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/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..cf6058e6f79
--- /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.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 afa66c48159..9f3da28fb39 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.LogicalExcept;
@@ -384,6 +389,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/AnalysisManagerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/statistics/AnalysisManagerTest.java
index f2c1f313044..df66b1b8346 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/statistics/AnalysisManagerTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/statistics/AnalysisManagerTest.java
@@ -23,9 +23,12 @@ 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.Table;
import org.apache.doris.catalog.TableIf;
+import org.apache.doris.catalog.TableProperty;
import org.apache.doris.common.Config;
import org.apache.doris.common.DdlException;
import org.apache.doris.common.Pair;
@@ -42,6 +45,8 @@ import org.apache.doris.nereids.trees.expressions.Slot;
import org.apache.doris.nereids.trees.expressions.SlotReference;
import org.apache.doris.nereids.trees.plans.commands.AnalyzeTableCommand;
import org.apache.doris.nereids.types.IntegerType;
+import org.apache.doris.persist.EditLog;
+import org.apache.doris.persist.TableStatsDeletionLog;
import org.apache.doris.statistics.AnalysisInfo.AnalysisMethod;
import org.apache.doris.statistics.AnalysisInfo.AnalysisType;
import org.apache.doris.statistics.AnalysisInfo.JobType;
@@ -51,6 +56,7 @@ 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 mockit.Mock;
import mockit.MockUp;
import mockit.Mocked;
@@ -68,6 +74,12 @@ 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;
@@ -75,8 +87,12 @@ import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
// CHECKSTYLE OFF
public class AnalysisManagerTest {
@@ -579,4 +595,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/StatisticsCleanerTest.java
b/fe/fe-core/src/test/java/org/apache/doris/statistics/StatisticsCleanerTest.java
index 32524b0c132..2eb2c601923 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/statistics/StatisticsCleanerTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/statistics/StatisticsCleanerTest.java
@@ -17,10 +17,16 @@
package org.apache.doris.statistics;
+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.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;
@@ -28,6 +34,9 @@ import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
+import java.util.ArrayList;
+import java.util.List;
+
class StatisticsCleanerTest {
@Test
@@ -50,4 +59,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/fe/fe-core/src/test/java/org/apache/doris/statistics/TableStatsMetaTest.java
b/fe/fe-core/src/test/java/org/apache/doris/statistics/TableStatsMetaTest.java
index 83e68e3090c..2a97dba45f6 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/statistics/TableStatsMetaTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/statistics/TableStatsMetaTest.java
@@ -17,20 +17,76 @@
package org.apache.doris.statistics;
+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 mockit.Mock;
import mockit.MockUp;
import mockit.Mocked;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
import java.util.HashSet;
import java.util.List;
+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(@Mocked OlapTable table) {
TableStatsMeta tableStatsMeta = new TableStatsMeta();
@@ -68,4 +124,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/regression-test/data/nereids_p0/eager_agg/scalar_agg_pushdown.out
b/regression-test/data/nereids_p0/eager_agg/scalar_agg_pushdown.out
index 8fa4341a049..975f6894552 100644
--- a/regression-test/data/nereids_p0/eager_agg/scalar_agg_pushdown.out
+++ b/regression-test/data/nereids_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/runtimefilterV2/rfv2.out
b/regression-test/data/query_p0/runtimefilterV2/rfv2.out
index 050c42ceaee..9659fc3df12 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 RFV2: RF0[a1->b1] RF1[a1->b1] RF2[a1->c1] RF3[a1->c1]
+----PhysicalIntersect RFV2: RF0[b1->a1] RF1[b1->a1] RF2[b1->c1] RF3[b1->c1]
------PhysicalDistribute[DistributionSpecHash]
---------PhysicalOlapScan[a]
+--------PhysicalOlapScan[b]
------PhysicalDistribute[DistributionSpecHash]
---------PhysicalOlapScan[b] RFV2: RF0 RF1
+--------PhysicalOlapScan[a] RFV2: RF0 RF1
------PhysicalDistribute[DistributionSpecHash]
--------PhysicalOlapScan[c] RFV2: RF2 RF3
-- !2 --
PhysicalResultSink
--PhysicalDistribute[DistributionSpecGather]
-----PhysicalIntersect RFV2: RF0[x->cast(b1 as BIGINT)] RF1[x->cast(b1 as
BIGINT)]
+----PhysicalIntersect RFV2: 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] RFV2: RF0 RF1
+----------PhysicalOlapScan[a] RFV2: RF0 RF1
-- !3 --
PhysicalResultSink
--PhysicalDistribute[DistributionSpecGather]
-----PhysicalIntersect RFV2: 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 RFV2: 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] RFV2: RF0 RF1
+----------PhysicalOlapScan[a] RFV2: RF0 RF1
------PhysicalDistribute[DistributionSpecHash]
--------PhysicalProject
----------PhysicalOlapScan[c] RFV2: 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 95e1d01aeae..fa0e822fa25 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 broadcast] hashCondition=((store.s_zip =
customer_address.ca_zip) and (store_sales.ss_store_sk = store.s_store_sk))
otherCondition=() build RFs:RF5 s_zip->[ca_zip];RF6 s_store_sk->[ss_store_sk]
+--------------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
(c_birth_country = upper(ca_country))))
----------------PhysicalProject
-------------------hashJoin[INNER_JOIN broadcast]
hashCondition=((customer.c_current_addr_sk = customer_address.ca_address_sk))
otherCondition=(( not (c_birth_country = upper(ca_country)))) build RFs:RF4
ca_address_sk->[c_current_addr_sk]
+------------------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))
otherCondition=() build RFs:RF3 c_customer_sk->[ss_customer_sk]
+----------------------hashJoin[INNER_JOIN broadcast]
hashCondition=((store_sales.ss_item_sk = item.i_item_sk)) otherCondition=()
------------------------PhysicalProject
---------------------------hashJoin[INNER_JOIN broadcast]
hashCondition=((store_sales.ss_item_sk = item.i_item_sk)) otherCondition=()
+--------------------------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 colocated]
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[store_sales] apply RFs: RF3
RF6
+----------------------------------PhysicalOlapScan[store_sales] apply RFs: RF2
--------------------------------PhysicalProject
----------------------------------PhysicalOlapScan[store_returns]
----------------------------PhysicalProject
-------------------------------PhysicalOlapScan[item]
+------------------------------filter((store.s_market_id = 8))
+--------------------------------PhysicalOlapScan[store]
------------------------PhysicalProject
---------------------------PhysicalOlapScan[customer] apply RFs: RF4
+--------------------------PhysicalOlapScan[item]
--------------------PhysicalProject
-----------------------PhysicalOlapScan[customer_address] apply RFs: RF5
+----------------------PhysicalOlapScan[customer]
----------------PhysicalProject
-------------------filter((store.s_market_id = 8))
---------------------PhysicalOlapScan[store]
+------------------PhysicalOlapScan[customer_address]
--PhysicalResultSink
----PhysicalQuickSort[MERGE_SORT]
------PhysicalDistribute[DistributionSpecGather]
diff --git
a/regression-test/suites/nereids_p0/eager_agg/scalar_agg_pushdown.groovy
b/regression-test/suites/nereids_p0/eager_agg/scalar_agg_pushdown.groovy
index c9dce6c678a..c9c8e9bdded 100644
--- a/regression-test/suites/nereids_p0/eager_agg/scalar_agg_pushdown.groovy
+++ b/regression-test/suites/nereids_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/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/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]