This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new d70d0a0218 [core] Do not push down MIN/MAX onto a column that has no
min/max statistics (#10593)
d70d0a0218 is described below
commit d70d0a0218b031091c4682538f5a9a371b4ac78a
Author: cxzl25 <[email protected]>
AuthorDate: Fri Oct 16 13:00:22 2026 +0800
[core] Do not push down MIN/MAX onto a column that has no min/max
statistics (#10593)
---
.../apache/paimon/table/source/PushDownUtils.java | 48 ++++++++++++++++++++++
.../source/aggregate/AggregatePushDownUtils.java | 7 +++-
.../apache/paimon/flink/BatchFileStoreITCase.java | 25 +++++++++++
.../spark/aggregate/AggregatePushDownUtils.scala | 6 ++-
.../paimon/spark/sql/PushDownAggregatesTest.scala | 26 ++++++++++++
5 files changed, 110 insertions(+), 2 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/source/PushDownUtils.java
b/paimon-core/src/main/java/org/apache/paimon/table/source/PushDownUtils.java
index cc9fc936cb..fd3946705a 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/source/PushDownUtils.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/source/PushDownUtils.java
@@ -19,15 +19,21 @@
package org.apache.paimon.table.source;
import org.apache.paimon.io.DataFileMeta;
+import org.apache.paimon.stats.SimpleStatsEvolution;
+import org.apache.paimon.stats.SimpleStatsEvolutions;
import org.apache.paimon.types.BigIntType;
import org.apache.paimon.types.BooleanType;
+import org.apache.paimon.types.DataField;
import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.DateType;
import org.apache.paimon.types.DoubleType;
import org.apache.paimon.types.FloatType;
import org.apache.paimon.types.IntType;
+import org.apache.paimon.types.RowType;
import org.apache.paimon.types.SmallIntType;
import org.apache.paimon.types.TinyIntType;
+import org.apache.paimon.utils.InternalRowUtils;
import java.util.HashSet;
import java.util.List;
@@ -81,6 +87,48 @@ public class PushDownUtils {
}
/** Returns whether every data file in the split has tight stats. */
+ /**
+ * Like {@link #minmaxAvailable(Split, Set)}, and additionally requires
every data file of the
+ * split to carry a min and a max for each column, or to hold only nulls
in that column. A file
+ * written with {@code stats-mode} {@code counts} for the column is listed
in its {@code
+ * valueStatsCols} but has no min and max, and {@link DataSplit#minValue}
could not tell it
+ * apart from a column that is null in every row.
+ */
+ public static boolean minmaxAvailable(
+ Split split, Set<String> columns, RowType rowType,
SimpleStatsEvolutions evolutions) {
+ if (!minmaxAvailable(split, columns)) {
+ return false;
+ }
+ DataSplit dataSplit = (DataSplit) split;
+ for (String column : columns) {
+ int fieldIndex = rowType.getFieldIndex(column);
+ DataField field = rowType.getField(column);
+ for (DataFileMeta file : dataSplit.dataFiles()) {
+ if (file.rowCount() == 0) {
+ continue;
+ }
+ SimpleStatsEvolution.Result stats =
+ evolutions
+ .getOrCreate(file.schemaId())
+ .evolution(
+ file.valueStats(), file.rowCount(),
file.valueStatsCols());
+ Object min = InternalRowUtils.get(stats.minValues(),
fieldIndex, field.type());
+ Object max = InternalRowUtils.get(stats.maxValues(),
fieldIndex, field.type());
+ if (min != null && max != null) {
+ continue;
+ }
+ Long nullCount =
+ (Long)
+ InternalRowUtils.get(
+ stats.nullCounts(), fieldIndex,
DataTypes.BIGINT());
+ if (nullCount == null || nullCount != file.rowCount()) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
public static boolean tightBoundsAvailable(Split split) {
if (!(split instanceof DataSplit)) {
return false;
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/aggregate/AggregatePushDownUtils.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/aggregate/AggregatePushDownUtils.java
index e2862729d1..17eeb55274 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/aggregate/AggregatePushDownUtils.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/aggregate/AggregatePushDownUtils.java
@@ -22,6 +22,7 @@ import org.apache.paimon.CoreOptions;
import org.apache.paimon.flink.LogicalTypeConversion;
import org.apache.paimon.partition.PartitionPredicate;
import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.stats.SimpleStatsEvolutions;
import org.apache.paimon.table.FileStoreTable;
import org.apache.paimon.table.source.DataSplit;
import org.apache.paimon.table.source.ReadBuilder;
@@ -159,6 +160,9 @@ public class AggregatePushDownUtils {
List<Split> splits = readBuilder.newScan().plan().splits();
List<DataSplit> dataSplits = new ArrayList<>(splits.size());
+ SimpleStatsEvolutions evolutions =
+ new SimpleStatsEvolutions(
+ id -> table.schemaManager().schema(id).fields(),
table.schema().id());
for (Split split : splits) {
if (!(split instanceof DataSplit)) {
return null;
@@ -169,7 +173,8 @@ public class AggregatePushDownUtils {
return null;
}
- if (!minMaxColumns.isEmpty() && !minmaxAvailable(split,
minMaxColumns)) {
+ if (!minMaxColumns.isEmpty()
+ && !minmaxAvailable(split, minMaxColumns, table.rowType(),
evolutions)) {
return null;
}
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BatchFileStoreITCase.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BatchFileStoreITCase.java
index 94f223f3de..288f72518e 100644
---
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BatchFileStoreITCase.java
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/BatchFileStoreITCase.java
@@ -928,6 +928,31 @@ public class BatchFileStoreITCase extends
CatalogITCaseBase {
validateAggregateNotPushDown(sql, "MinAggFunction");
}
+ @Test
+ public void testMinMaxCountsStatsModeNotPushDown() {
+ // a column with stats-mode `counts` has a null count but no min/max
+ sql(
+ "CREATE TABLE min_max_counts (f0 INT, f1 INT) "
+ + "WITH ('fields.f0.stats-mode' = 'counts')");
+ sql("INSERT INTO min_max_counts VALUES (1, 10), (2, 20), (3, CAST(NULL
AS INT))");
+
+ String sql = "SELECT MIN(f0), MAX(f0) FROM min_max_counts";
+ assertThat(sql(sql)).containsOnly(Row.of(1, 3));
+ validateAggregateNotPushDown(sql, "MinAggFunction", "MaxAggFunction");
+
+ sql = "SELECT MIN(f1), MAX(f1) FROM min_max_counts";
+ assertThat(sql(sql)).containsOnly(Row.of(10, 20));
+ validateAggregatePushDown(sql, "MinAggFunction", "MaxAggFunction");
+
+ sql(
+ "CREATE TABLE min_max_counts_all (f0 INT, f1 INT) "
+ + "WITH ('metadata.stats-mode' = 'counts')");
+ sql("INSERT INTO min_max_counts_all VALUES (1, 10), (2, 20)");
+ sql = "SELECT MIN(f0), MAX(f1) FROM min_max_counts_all";
+ assertThat(sql(sql)).containsOnly(Row.of(1, 20));
+ validateAggregateNotPushDown(sql, "MinAggFunction", "MaxAggFunction");
+ }
+
@Test
public void testMinMaxPKNotPushDown() {
sql("CREATE TABLE min_max_pk (f0 INT PRIMARY KEY NOT ENFORCED, f1
INT)");
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/aggregate/AggregatePushDownUtils.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/aggregate/AggregatePushDownUtils.scala
index c2c82989e6..1e9e721e65 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/aggregate/AggregatePushDownUtils.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/aggregate/AggregatePushDownUtils.scala
@@ -18,6 +18,7 @@
package org.apache.paimon.spark.aggregate
+import org.apache.paimon.stats.SimpleStatsEvolutions
import org.apache.paimon.table.FileStoreTable
import org.apache.paimon.table.source.{DataSplit, ReadBuilder, Split}
import org.apache.paimon.table.source.PushDownUtils.{minmaxAvailable,
tightBoundsAvailable}
@@ -56,7 +57,10 @@ object AggregatePushDownUtils {
return None
}
val splits = generateSplits(readBuilder)
- if (!splits.forall(minmaxAvailable(_, columns.asJava))) {
+ val evolutions = new SimpleStatsEvolutions(
+ sid => table.schemaManager().schema(sid).fields(),
+ table.schema().id())
+ if (!splits.forall(minmaxAvailable(_, columns.asJava, rowType,
evolutions))) {
return None
}
if (!splits.forall(tightBoundsAvailable)) {
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PushDownAggregatesTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PushDownAggregatesTest.scala
index 875fd28577..33f4afad10 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PushDownAggregatesTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/PushDownAggregatesTest.scala
@@ -227,6 +227,32 @@ class PushDownAggregatesTest extends PaimonSparkTestBase
with AdaptiveSparkPlanH
}
}
+ test("Push down aggregate - column without min/max statistics") {
+ // A column whose stats-mode is `counts` carries a null count but no
min/max, which must not
+ // be mistaken for a column that is all null.
+ Seq("'fields.c1.stats-mode' = 'counts'", "'metadata.stats-mode' =
'counts'").foreach {
+ statsMode =>
+ withTable("T") {
+ spark.sql(s"CREATE TABLE T (c1 INT, c2 INT)
TBLPROPERTIES($statsMode)")
+ spark.sql("INSERT INTO T VALUES (1, 10), (2, 20), (3, null)")
+ runAndCheckAggregate("SELECT COUNT(*) FROM T", Row(3) :: Nil, 0)
+ runAndCheckAggregate("SELECT MIN(c1), MAX(c1) FROM T", Row(1, 3) ::
Nil, 2)
+ runAndCheckAggregate("SELECT MIN(c1), MAX(c2) FROM T", Row(1, 20) ::
Nil, 2)
+ }
+ }
+
+ withTable("T") {
+ spark.sql("CREATE TABLE T (c1 INT, c2 INT)
TBLPROPERTIES('fields.c1.stats-mode' = 'counts')")
+ spark.sql("INSERT INTO T VALUES (1, 10), (2, 20), (3, null)")
+ // c2 still has full statistics
+ runAndCheckAggregate("SELECT MIN(c2), MAX(c2) FROM T", Row(10, 20) ::
Nil, 0)
+ // A column added after the data was written is null in every existing
file
+ spark.sql("ALTER TABLE T ADD COLUMN c3 INT")
+ spark.sql("INSERT INTO T VALUES (4, 40, 400)")
+ runAndCheckAggregate("SELECT MIN(c3), MAX(c3) FROM T", Row(400, 400) ::
Nil, 0)
+ }
+ }
+
test("Push down aggregate - primary key table with deletion vector") {
Seq(true, false).foreach(
deletionVectorsEnabled => {