This is an automated email from the ASF dual-hosted git repository.
englefly pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new cd71f991355 [fix](fe) Do not scale numNulls of partition statistics
again (#69636)
cd71f991355 is described below
commit cd71f9913556f65ce3b08696fbae8769dbf80be6
Author: minghong <[email protected]>
AuthorDate: Tue Oct 27 15:49:03 2026 +0800
[fix](fe) Do not scale numNulls of partition statistics again (#69636)
### What problem does this PR solve?
Problem Summary:
When only a subset of partitions is selected,
`StatsCalculator.computeOlapScan` scales `numNulls` of the column
statistics by `selectedPartitionsRowCount / tableRowCount`, because the
table level statistics cover the whole table.
With `enable_partition_analyze = true` the statistics of the selected
partitions are used instead, and their `numNulls` already only covers
the selected partitions. The scaling was applied to them anyway, so
`numNulls` was scaled a second time and became too small.
Reproduction: a table with 3 partitions of 4 rows each and 1 null in
`val` per partition. Pruning to one partition (4 of 12 rows) reports
`numNulls = 0.3333` in `EXPLAIN MEMO PLAN` instead of `1`, and `select *
from t where val is null` is estimated to return `0.33` rows instead of
`1`:
```
val#1 -> ndv=1.0000, min=2.000000(2), max=2.000000(2), count=4.0000,
numNulls=0.3333, ...
```
Fix:
- make the partition statistics helper return `null` instead of silently
falling back to the table level statistics, so that the caller knows
which scope the returned statistic has;
- scale `numNulls` only when the table level statistics are really used,
i.e. when `enable_partition_analyze` is off, or when the partition
statistics are unavailable and the scan falls back to the table level
ones.
### Release note
None
### Check List (For Author)
- Test: Unit Test
-
`StatsCalculatorTest#testComputeOlapScanDoesNotScaleNumNullsOfPartitionStatistics`
reproduces the bug: without the fix it fails with `expected: <1.0> but
was: <0.3333333333333333>`.
-
`StatsCalculatorTest#testComputeOlapScanFallsBackToTableStatisticsWhenPartitionStatisticIsUnknown`
covers the fallback path, which still has to be scaled.
-
`StatsCalculatorTest#testComputeOlapScanScalesNumNullsForSelectedPartitions`
(existing case, table level statistics) still passes.
- `mvn -pl fe-core -am test -Dtest=StatsCalculatorTest` passes (19
tests), checkstyle passes.
- Behavior changed: No
- Does this need documentation: No
---
.../doris/nereids/stats/StatsCalculator.java | 84 +++++++-----
.../doris/nereids/stats/StatsCalculatorTest.java | 145 +++++++++++++++++----
2 files changed, 171 insertions(+), 58 deletions(-)
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 e3071cfd25f..e1482c1bdcd 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
@@ -588,10 +588,20 @@ public class StatsCalculator extends
DefaultPlanVisitor<Statistics, Void> {
&&
connectContext.getSessionVariable().enablePartitionAnalyze;
for (SlotReference slot : visibleOutputSlots) {
ColumnStatistic cache;
+ // numNulls of the table level statistics covers the whole
table, so it has to be scaled
+ // to the selected partitions. numNulls of the partition level
statistics is already
+ // scoped to them and must not be scaled again.
+ boolean scaleNumNulls;
if (enablePartitionStatics) {
- cache = getColumnStatistic(olapTableStats, slot.getName(),
selectedPartitionNames);
+ cache = getPartitionColumnStatistic(olapTableStats,
slot.getName(), selectedPartitionNames);
+ scaleNumNulls = cache == null;
+ if (scaleNumNulls) {
+ // partition level statistics are unavailable, fall
back to table level statistics
+ cache =
olapTableStats.getColumnStatistics(slot.getName(), connectContext);
+ }
} else {
cache = olapTableStats.getColumnStatistics(slot.getName(),
connectContext);
+ scaleNumNulls = true;
}
if (slot.getOriginalColumn().isPresent()) {
cache = updateMinMaxForPartitionKey(olapTable,
selectedPartitionNames, slot, cache);
@@ -599,9 +609,10 @@ public class StatsCalculator extends
DefaultPlanVisitor<Statistics, Void> {
ColumnStatisticBuilder colStatsBuilder = new
ColumnStatisticBuilder(cache,
selectedPartitionsRowCount);
colStatsBuilder.normalizeAvgSizeByte(slot.getDataType());
- //scale null_num
- double scale = tableRowCount == 0 ? 1 :
selectedPartitionsRowCount / tableRowCount;
- colStatsBuilder.setNumNulls(colStatsBuilder.getNumNulls() *
scale);
+ if (scaleNumNulls) {
+ double scale = tableRowCount == 0 ? 1 :
selectedPartitionsRowCount / tableRowCount;
+ colStatsBuilder.setNumNulls(colStatsBuilder.getNumNulls()
* scale);
+ }
builder.putColumnStatistics(slot, colStatsBuilder.build());
}
checkIfUnknownStatsUsedAsKey(builder);
@@ -1206,46 +1217,47 @@ public class StatsCalculator extends
DefaultPlanVisitor<Statistics, Void> {
}
/**
- * - partition pruned: try to get partition col stats, if failed, then
fall back to table level col stats
- * - no partition pruned: get table level col stats
+ * Merge the column statistics of the given partitions.
+ *
+ * <p>The returned statistic is scoped to the given partitions, i.e. its
count and numNulls only
+ * cover them. Returns null if the statistics of any partition is unknown,
so that the caller can
+ * decide whether to fall back to the table level column statistics.
*/
- private ColumnStatistic getColumnStatistic(
+ private ColumnStatistic getPartitionColumnStatistic(
OlapTableStatistics olapTableStatistics, String colName,
List<String> partitionNames) {
if (connectContext != null &&
connectContext.getState().isPlanWithUnKnownColumnStats()) {
return ColumnStatistic.UNKNOWN;
}
- if (!partitionNames.isEmpty()) {
- PartitionColumnStatisticBuilder builder = new
PartitionColumnStatisticBuilder();
- boolean hasUnknown = false;
- // check if there is any unknown stats to avoid unnecessary
partition column stats merge.
- List<PartitionColumnStatistic> pColStatsLists = new
ArrayList<>(partitionNames.size());
- for (String partitionName : partitionNames) {
- PartitionColumnStatistic pcolStats
- =
olapTableStatistics.getPartitionColumnStatistics(partitionName, colName,
connectContext);
- if (pcolStats.isUnKnown) {
- hasUnknown = true;
- break;
- } else {
- pColStatsLists.add(pcolStats);
- }
+ if (partitionNames.isEmpty()) {
+ return null;
+ }
+ PartitionColumnStatisticBuilder builder = new
PartitionColumnStatisticBuilder();
+ boolean hasUnknown = false;
+ // check if there is any unknown stats to avoid unnecessary partition
column stats merge.
+ List<PartitionColumnStatistic> pColStatsLists = new
ArrayList<>(partitionNames.size());
+ for (String partitionName : partitionNames) {
+ PartitionColumnStatistic pcolStats
+ =
olapTableStatistics.getPartitionColumnStatistics(partitionName, colName,
connectContext);
+ if (pcolStats.isUnKnown) {
+ hasUnknown = true;
+ break;
}
- if (!hasUnknown) {
- boolean isFirst = true;
- // try to merge partition column stats
- for (PartitionColumnStatistic pcolStats : pColStatsLists) {
- if (isFirst) {
- builder = new
PartitionColumnStatisticBuilder(pcolStats);
- isFirst = false;
- } else {
- builder.merge(pcolStats);
- }
- }
- return builder.toColumnStatistics();
+ pColStatsLists.add(pcolStats);
+ }
+ if (hasUnknown) {
+ return null;
+ }
+ boolean isFirst = true;
+ // try to merge partition column stats
+ for (PartitionColumnStatistic pcolStats : pColStatsLists) {
+ if (isFirst) {
+ builder = new PartitionColumnStatisticBuilder(pcolStats);
+ isFirst = false;
+ } else {
+ builder.merge(pcolStats);
}
}
-
- // if any partition-col-stats is unknown, fall back to table level col
stats
- return olapTableStatistics.getColumnStatistics(colName,
connectContext);
+ return builder.toColumnStatistics();
}
/**
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 078368cb71a..f5bbc72e3db 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
@@ -61,7 +61,10 @@ import org.apache.doris.statistics.analysis.AnalysisManager;
import org.apache.doris.statistics.cache.StatisticsCache;
import org.apache.doris.statistics.model.ColumnStatistic;
import org.apache.doris.statistics.model.ColumnStatisticBuilder;
+import org.apache.doris.statistics.model.PartitionColumnStatistic;
+import org.apache.doris.statistics.model.PartitionColumnStatisticBuilder;
import org.apache.doris.statistics.model.Statistics;
+import org.apache.doris.statistics.util.Hll128;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -214,9 +217,98 @@ public class StatsCalculatorTest {
@Test
public void testComputeOlapScanScalesNumNullsForSelectedPartitions() {
+ // table level statistics: the whole table has 12 rows and 3 nulls,
the selected partition has 4 rows
+ try (MockPartitionPrunedScan fixture = mockPartitionPrunedScan(false,
newTableStatistic(1), null)) {
+ Statistics statistics = fixture.computeOlapScan();
+ ColumnStatistic result =
statistics.findColumnStatistics(fixture.slot);
+
+ Assertions.assertEquals(4, statistics.getRowCount(), 0.001);
+ Assertions.assertNotNull(result);
+ Assertions.assertEquals(1, result.ndv, 0.001);
+ Assertions.assertEquals(2, result.minValue, 0.001);
+ Assertions.assertEquals(2, result.maxValue, 0.001);
+ Assertions.assertEquals(4, result.count, 0.001);
+ Assertions.assertEquals(1, result.numNulls, 0.001);
+ Assertions.assertEquals("2", result.minExpr.getStringValue());
+ Assertions.assertEquals("2", result.maxExpr.getStringValue());
+ }
+ }
+
+ @Test
+ public void testComputeOlapScanDoesNotScaleNumNullsOfPartitionStatistics()
{
+ // partition level statistics of the selected partition: 4 rows and 1
of them is null
+ PartitionColumnStatistic partitionStatistic = new
PartitionColumnStatisticBuilder(4)
+ .setNdv(newHll128(2))
+ .setNumNulls(1)
+ .setMinValue(2)
+ .setMaxValue(2)
+ .setMinExpr(new IntLiteral(2))
+ .setMaxExpr(new IntLiteral(2))
+ .build();
+ try (MockPartitionPrunedScan fixture = mockPartitionPrunedScan(true,
+ newTableStatistic(2), partitionStatistic)) {
+ Statistics statistics = fixture.computeOlapScan();
+ ColumnStatistic result =
statistics.findColumnStatistics(fixture.slot);
+
+ Assertions.assertEquals(4, statistics.getRowCount(), 0.001);
+ Assertions.assertNotNull(result);
+ // ndv comes from the partition level statistics, the table level
ones have ndv=2
+ Assertions.assertEquals(1, result.ndv, 0.001);
+ Assertions.assertEquals(4, result.count, 0.001);
+ // numNulls of the partition level statistics already covers the
selected partition only,
+ // it must not be scaled by selectedPartitionsRowCount /
tableRowCount (1 * 4 / 12) again
+ Assertions.assertEquals(1, result.numNulls, 0.001);
+ }
+ }
+
+ @Test
+ public void
testComputeOlapScanFallsBackToTableStatisticsWhenPartitionStatisticIsUnknown() {
+ try (MockPartitionPrunedScan fixture = mockPartitionPrunedScan(true,
+ newTableStatistic(2), PartitionColumnStatistic.UNKNOWN)) {
+ Statistics statistics = fixture.computeOlapScan();
+ ColumnStatistic result =
statistics.findColumnStatistics(fixture.slot);
+
+ Assertions.assertEquals(4, statistics.getRowCount(), 0.001);
+ Assertions.assertNotNull(result);
+ // ndv comes from the table level statistics, i.e. it fell back to
them
+ Assertions.assertEquals(2, result.ndv, 0.001);
+ Assertions.assertEquals(4, result.count, 0.001);
+ // the table level statistic covers the whole table, so numNulls
is scaled: 3 * 4 / 12
+ Assertions.assertEquals(1, result.numNulls, 0.001);
+ }
+ }
+
+ private static ColumnStatistic newTableStatistic(double ndv) {
+ return new ColumnStatisticBuilder(12)
+ .setNdv(ndv)
+ .setMinValue(2)
+ .setMaxValue(2)
+ .setMinExpr(new IntLiteral(2))
+ .setMaxExpr(new IntLiteral(2))
+ .setNumNulls(3)
+ .build();
+ }
+
+ private static Hll128 newHll128(long value) {
+ Hll128 hll128 = new Hll128();
+ hll128.update(value);
+ return hll128;
+ }
+
+ /**
+ * Mock an OLAP scan over a table with 3 partitions and 12 rows, whose
partition pruning selects
+ * exactly one partition with 4 rows.
+ *
+ * @param enablePartitionStatics value of the session variable
enable_partition_analyze
+ * @param tableStatistic statistics of the whole table
+ * @param partitionStatistic statistics of the selected partition, null if
not stubbed
+ */
+ private static MockPartitionPrunedScan mockPartitionPrunedScan(boolean
enablePartitionStatics,
+ ColumnStatistic tableStatistic, PartitionColumnStatistic
partitionStatistic) {
ConnectContext previousContext = ConnectContext.get();
ConnectContext connectContext = new ConnectContext();
connectContext.setThreadLocalInfo();
+ connectContext.getSessionVariable().enablePartitionAnalyze =
enablePartitionStatics;
Env env = Mockito.mock(Env.class);
AnalysisManager analysisManager = Mockito.mock(AnalysisManager.class);
@@ -232,19 +324,15 @@ public class StatsCalculatorTest {
Column column = new Column("val", PrimitiveType.INT);
SlotReference slot = new SlotReference(new ExprId(1), "val",
IntegerType.INSTANCE, true,
ImmutableList.of("test", "tbl"), table, column, table, column);
- ColumnStatistic tableStatistic = new ColumnStatisticBuilder(12)
- .setNdv(1)
- .setMinValue(2)
- .setMaxValue(2)
- .setMinExpr(new IntLiteral(2))
- .setMaxExpr(new IntLiteral(2))
- .setNumNulls(3)
- .build();
Mockito.when(env.getAnalysisManager()).thenReturn(analysisManager);
Mockito.when(env.getStatisticsCache()).thenReturn(statisticsCache);
Mockito.when(statisticsCache.getOlapTableStats(scan)).thenReturn(olapTableStatistics);
Mockito.when(olapTableStatistics.getColumnStatistics("val",
connectContext)).thenReturn(tableStatistic);
+ if (partitionStatistic != null) {
+
Mockito.when(olapTableStatistics.getPartitionColumnStatistics("p1", "val",
connectContext))
+ .thenReturn(partitionStatistic);
+ }
Mockito.when(scan.getTable()).thenReturn(table);
Mockito.when(scan.getSelectedIndexId()).thenReturn(baseIndexId);
Mockito.when(scan.getSelectedPartitionIds()).thenReturn(ImmutableList.of(selectedPartitionId));
@@ -258,22 +346,35 @@ public class StatsCalculatorTest {
Mockito.when(table.getQualifiedDbName()).thenReturn("test");
Mockito.when(selectedPartition.getName()).thenReturn("p1");
- try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
- mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class);
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ return new MockPartitionPrunedScan(previousContext, scan, slot,
mockedEnv);
+ }
- Statistics statistics = new StatsCalculator((CascadesContext)
null).computeOlapScan(scan);
- ColumnStatistic result = statistics.findColumnStatistics(slot);
+ /**
+ * Mocks of {@link #mockPartitionPrunedScan}, they have to be closed after
computing the statistics.
+ */
+ private static final class MockPartitionPrunedScan implements
AutoCloseable {
+ private final ConnectContext previousContext;
+ private final LogicalOlapScan scan;
+ private final SlotReference slot;
+ private final MockedStatic<Env> mockedEnv;
+
+ private MockPartitionPrunedScan(ConnectContext previousContext,
LogicalOlapScan scan, SlotReference slot,
+ MockedStatic<Env> mockedEnv) {
+ this.previousContext = previousContext;
+ this.scan = scan;
+ this.slot = slot;
+ this.mockedEnv = mockedEnv;
+ }
- Assertions.assertEquals(4, statistics.getRowCount(), 0.001);
- Assertions.assertNotNull(result);
- Assertions.assertEquals(1, result.ndv, 0.001);
- Assertions.assertEquals(2, result.minValue, 0.001);
- Assertions.assertEquals(2, result.maxValue, 0.001);
- Assertions.assertEquals(4, result.count, 0.001);
- Assertions.assertEquals(1, result.numNulls, 0.001);
- Assertions.assertEquals("2", result.minExpr.getStringValue());
- Assertions.assertEquals("2", result.maxExpr.getStringValue());
- } finally {
+ private Statistics computeOlapScan() {
+ return new StatsCalculator((CascadesContext)
null).computeOlapScan(scan);
+ }
+
+ @Override
+ public void close() {
+ mockedEnv.close();
ConnectContext.remove();
if (previousContext != null) {
previousContext.setThreadLocalInfo();
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]