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 d904d00009 [core][flink][spark] Treat non-positive row counts as
unknown (#9456)
d904d00009 is described below
commit d904d000091339b4b030bb581a9e4ebd45785b45
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Sat Aug 29 11:25:03 2026 +0800
[core][flink][spark] Treat non-positive row counts as unknown (#9456)
### Purpose
Follow up on #9379 by making row-count inference conservative across
Paimon table scans.
Paimon split and scan statistics use non-positive row counts for unknown
values, while HMS-compatible catalogs may expose any value less than or
equal to zero when statistics are unavailable. Treating such a value as
an exact row count can make an optimizer plan against an apparently
empty scan that still contains data.
This change:
- treats non-positive catalog partition row counts as unknown in Core
format-table scan plans;
- makes Spark reject non-positive scan and split row counts, while
preserving exact zero for structurally empty scans and positive
scan-level statistics;
- makes Flink return `TableStats.UNKNOWN` for non-positive or overflowed
row-count sums from both partition entries and splits;
- uses checked addition so overflow cannot turn a large table into a
misleading row count.
Empty input collections still produce an exact zero. There is no public
API change.
### Tests
- [x] `CatalogManagedPartitionScanTest` (23 tests)
- [x] `PaimonStatisticsTest` (5 tests)
- [x] `FlinkTableSourceStatisticsTest` (3 tests)
- [x] `CatalogManagedPartitionAnalyzeTest` Spark integration suite (22
tests)
- [x] Spotless apply/check for Core, Flink common, and Spark common
---
.../table/format/CatalogSplitEnumerator.java | 7 +-
.../format/CatalogManagedPartitionScanTest.java | 52 ++++++++++
.../paimon/flink/source/FlinkTableSource.java | 32 +++++-
.../source/FlinkTableSourceStatisticsTest.java | 114 +++++++++++++++++++++
.../paimon/spark/read/PaimonStatistics.scala | 29 ++++--
.../paimon/spark/read/PaimonStatisticsTest.scala | 75 ++++++++++++++
6 files changed, 298 insertions(+), 11 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogSplitEnumerator.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogSplitEnumerator.java
index bbfad3e836..9095df296f 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogSplitEnumerator.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogSplitEnumerator.java
@@ -26,7 +26,6 @@ import org.apache.paimon.fs.Path;
import org.apache.paimon.manifest.PartitionEntry;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionPredicate;
-import org.apache.paimon.partition.PartitionStatistics;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.source.Split;
@@ -202,7 +201,11 @@ final class CatalogSplitEnumerator extends SplitEnumerator
{
private OptionalLong rowCount(List<PartitionEntry> entries) {
long rowCount = 0L;
for (PartitionEntry entry : entries) {
- if (!PartitionStatistics.isKnown(entry.recordCount())) {
+ // At this scan-estimation boundary, only positive catalog counts
are trustworthy.
+ // Catalogs such as HMS overload non-positive values for
unavailable statistics, so an
+ // entry carrying zero cannot safely prove that its partition is
empty. An empty entry
+ // list still reaches the exact structural result of zero below.
+ if (entry.recordCount() <= 0) {
return OptionalLong.empty();
}
try {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
index 2563a54b35..c1f644febc 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
@@ -207,6 +207,43 @@ class CatalogManagedPartitionScanTest {
verify(catalog).listPartitionsPaged(IDENTIFIER, 1000, null, null);
}
+ @Test
+ void testPlanRowCountStaysUnknownWhenCatalogReportsZero() throws Exception
{
+ Catalog catalog = mock(Catalog.class);
+ Partition catalogPartition =
+ new Partition(partition("2025", "11").spec(), 0L, 1L, 1L, 0L,
-1, false);
+ when(catalog.listPartitionsPaged(eq(IDENTIFIER), eq(1000), isNull(),
isNull()))
+ .thenReturn(new
PagedList<>(Collections.singletonList(catalogPartition), null));
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ writeDataFile(fileIO, tablePath, "year=2025/month=11");
+ FormatTable table = createTable(fileIO, tablePath,
partitionManager(catalog), false);
+
+ FormatTableScan.Plan plan = new FormatTableScan(table, null,
null).plan();
+
+ assertThat(plan.splits()).hasSize(1);
+ assertThat(plan.rowCount()).isEqualTo(OptionalLong.empty());
+ }
+
+ @Test
+ void testPlanRowCountStaysUnknownWhenOneCatalogPartitionReportsZero()
throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ Partition zero = new Partition(partition("2025", "10").spec(), 0L, 0L,
0L, 0L, -1, false);
+ Partition positive =
+ new Partition(partition("2025", "11").spec(), 3L, 1L, 1L, 0L,
-1, false);
+ when(catalog.listPartitionsPaged(eq(IDENTIFIER), eq(1000), isNull(),
isNull()))
+ .thenReturn(new PagedList<>(Arrays.asList(zero, positive),
null));
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ writeDataFile(fileIO, tablePath, "year=2025/month=11");
+ FormatTable table = createTable(fileIO, tablePath,
partitionManager(catalog), false);
+
+ FormatTableScan.Plan plan = new FormatTableScan(table, null,
null).plan();
+
+ assertThat(plan.splits()).hasSize(1);
+ assertThat(plan.rowCount()).isEqualTo(OptionalLong.empty());
+ }
+
@Test
void testPlanRowCountStaysUnknownWhenCatalogReportsNoStatistics() throws
Exception {
// Partitions as they come off the wire from a catalog that stores no
statistics. Summing
@@ -232,6 +269,21 @@ class CatalogManagedPartitionScanTest {
assertThat(plan.rowCount()).isEqualTo(OptionalLong.empty());
}
+ @Test
+ void testPlanRowCountIsZeroWhenCatalogReturnsNoPartitions() throws
Exception {
+ Catalog catalog = mock(Catalog.class);
+ when(catalog.listPartitionsPaged(eq(IDENTIFIER), eq(1000), isNull(),
isNull()))
+ .thenReturn(new PagedList<>(Collections.emptyList(), null));
+ LocalFileIO fileIO = LocalFileIO.create();
+ Path tablePath = new Path(tempDir.toUri());
+ FormatTable table = createTable(fileIO, tablePath,
partitionManager(catalog), false);
+
+ FormatTableScan.Plan plan = new FormatTableScan(table, null,
null).plan();
+
+ assertThat(plan.splits()).isEmpty();
+ assertThat(plan.rowCount()).isEqualTo(OptionalLong.of(0L));
+ }
+
@Test
void testUnderscoreInPartitionNameRemainsLiteralPrefix() {
LocalFileIO fileIO = LocalFileIO.create();
diff --git
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
index 534783fb02..dd4b7439e0 100644
---
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
+++
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
@@ -46,6 +46,7 @@ import
org.apache.flink.table.connector.source.abilities.SupportsFilterPushDown;
import org.apache.flink.table.connector.source.abilities.SupportsLimitPushDown;
import
org.apache.flink.table.connector.source.abilities.SupportsProjectionPushDown;
import org.apache.flink.table.expressions.ResolvedExpression;
+import org.apache.flink.table.plan.stats.TableStats;
import org.apache.flink.table.types.DataType;
import org.apache.flink.table.types.logical.RowType;
import org.slf4j.Logger;
@@ -56,6 +57,8 @@ import javax.annotation.Nullable;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
+import java.util.PrimitiveIterator;
+import java.util.stream.LongStream;
import static org.apache.paimon.flink.FlinkConnectorOptions.SCAN_PARTITIONS;
import static org.apache.paimon.options.OptionsUtils.PAIMON_PREFIX;
@@ -257,11 +260,12 @@ public abstract class FlinkTableSource
.newScan()
.listPartitionEntries();
long totalSize = 0;
- long rowCount = 0;
for (PartitionEntry entry : partitionEntries) {
totalSize += entry.fileSizeInBytes();
- rowCount += entry.recordCount();
}
+ long rowCount =
+ sumRowCounts(
+
partitionEntries.stream().mapToLong(PartitionEntry::recordCount));
long splitTargetSize = ((DataTable)
table).coreOptions().splitTargetSize();
splitStatistics =
new SplitStatistics((int) (totalSize / splitTargetSize
+ 1), rowCount);
@@ -277,11 +281,33 @@ public abstract class FlinkTableSource
.splits();
splitStatistics =
new SplitStatistics(
- splits.size(),
splits.stream().mapToLong(Split::rowCount).sum());
+ splits.size(),
+
sumRowCounts(splits.stream().mapToLong(Split::rowCount)));
}
}
}
+ /**
+ * Returns zero for an empty stream and Flink's unknown row-count sentinel
if any value is
+ * non-positive or the sum overflows.
+ */
+ static long sumRowCounts(LongStream rowCounts) {
+ PrimitiveIterator.OfLong iterator = rowCounts.iterator();
+ long totalRowCount = 0L;
+ while (iterator.hasNext()) {
+ long rowCount = iterator.nextLong();
+ if (rowCount <= 0) {
+ return TableStats.UNKNOWN.getRowCount();
+ }
+ try {
+ totalRowCount = Math.addExact(totalRowCount, rowCount);
+ } catch (ArithmeticException e) {
+ return TableStats.UNKNOWN.getRowCount();
+ }
+ }
+ return totalRowCount;
+ }
+
/** Split statistics for inferring row count and parallelism size. */
protected static class SplitStatistics {
diff --git
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FlinkTableSourceStatisticsTest.java
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FlinkTableSourceStatisticsTest.java
new file mode 100644
index 0000000000..7c73eceffa
--- /dev/null
+++
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/FlinkTableSourceStatisticsTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.paimon.flink.source;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.manifest.PartitionEntry;
+import org.apache.paimon.table.DataTable;
+import org.apache.paimon.table.Table;
+import org.apache.paimon.table.source.ReadBuilder;
+import org.apache.paimon.table.source.Split;
+import org.apache.paimon.table.source.TableScan;
+
+import org.apache.flink.table.catalog.ObjectIdentifier;
+import org.apache.flink.table.plan.stats.TableStats;
+import org.junit.jupiter.api.Test;
+import org.mockito.Answers;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Optional;
+import java.util.stream.LongStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests for row count inference in {@link FlinkTableSource}. */
+class FlinkTableSourceStatisticsTest {
+
+ @Test
+ void testSumRowCounts() {
+ assertThat(FlinkTableSource.sumRowCounts(LongStream.empty())).isZero();
+ assertThat(FlinkTableSource.sumRowCounts(LongStream.of(2L,
3L))).isEqualTo(5L);
+
+ long unknown = TableStats.UNKNOWN.getRowCount();
+
assertThat(FlinkTableSource.sumRowCounts(LongStream.of(0L))).isEqualTo(unknown);
+ assertThat(FlinkTableSource.sumRowCounts(LongStream.of(3L,
0L))).isEqualTo(unknown);
+
assertThat(FlinkTableSource.sumRowCounts(LongStream.of(-1L))).isEqualTo(unknown);
+ assertThat(FlinkTableSource.sumRowCounts(LongStream.of(3L,
-2L))).isEqualTo(unknown);
+ assertThat(FlinkTableSource.sumRowCounts(LongStream.of(Long.MAX_VALUE,
1L)))
+ .isEqualTo(unknown);
+ }
+
+ @Test
+ void testNonDataTableInferenceReturnsUnknownForUnknownSplitRowCount() {
+ Table table = mock(Table.class);
+ when(table.options()).thenReturn(Collections.emptyMap());
+ when(table.statistics()).thenReturn(Optional.empty());
+ ReadBuilder readBuilder = mock(ReadBuilder.class,
Answers.RETURNS_SELF);
+ when(table.newReadBuilder()).thenReturn(readBuilder);
+ TableScan scan = mock(TableScan.class);
+ when(readBuilder.newScan()).thenReturn(scan);
+ TableScan.Plan plan = mock(TableScan.Plan.class);
+ when(scan.plan()).thenReturn(plan);
+ Split known = split(5L);
+ Split unknown = split(-1L);
+ when(plan.splits()).thenReturn(Arrays.asList(known, unknown));
+
+ DataTableSource source =
+ new DataTableSource(
+ ObjectIdentifier.of("catalog", "database", "table"),
table, false, null);
+
+ assertThat(source.reportStatistics().getRowCount())
+ .isEqualTo(TableStats.UNKNOWN.getRowCount());
+ }
+
+ @Test
+ void testDataTableInferenceReturnsUnknownForUnknownPartitionRowCount() {
+ DataTable table = mock(DataTable.class);
+ when(table.options()).thenReturn(Collections.emptyMap());
+ when(table.statistics()).thenReturn(Optional.empty());
+ CoreOptions coreOptions = mock(CoreOptions.class);
+ when(coreOptions.splitTargetSize()).thenReturn(128L);
+ when(table.coreOptions()).thenReturn(coreOptions);
+ ReadBuilder readBuilder = mock(ReadBuilder.class,
Answers.RETURNS_SELF);
+ when(table.newReadBuilder()).thenReturn(readBuilder);
+ TableScan scan = mock(TableScan.class);
+ when(readBuilder.newScan()).thenReturn(scan);
+ PartitionEntry known = mock(PartitionEntry.class);
+ when(known.recordCount()).thenReturn(5L);
+ PartitionEntry unknown = mock(PartitionEntry.class);
+ when(unknown.recordCount()).thenReturn(0L);
+ when(scan.listPartitionEntries()).thenReturn(Arrays.asList(known,
unknown));
+
+ DataTableSource source =
+ new DataTableSource(
+ ObjectIdentifier.of("catalog", "database", "table"),
table, false, null);
+
+ assertThat(source.reportStatistics().getRowCount())
+ .isEqualTo(TableStats.UNKNOWN.getRowCount());
+ }
+
+ private static Split split(long rowCount) {
+ Split split = mock(Split.class);
+ when(split.rowCount()).thenReturn(rowCount);
+ return split;
+ }
+}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/PaimonStatistics.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/PaimonStatistics.scala
index 9df3e683b4..f281c25ac9 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/PaimonStatistics.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/read/PaimonStatistics.scala
@@ -47,18 +47,35 @@ case class PaimonStatistics(
lazy val numRows: OptionalLong = {
if (scanRowCount.isPresent) {
- // A catalog may report zero because it cannot tell "never measured"
from "measured, and
- // empty". Over real bytes leave it unknown; over zero bytes the scan
really is empty.
- if (scanRowCount.getAsLong > 0 || fileTotalSize == 0) {
+ val rowCount = scanRowCount.getAsLong
+ // A scan may use a non-positive row count for unknown. Only zero from a
scan with no splits
+ // proves that the result is empty.
+ if (rowCount > 0 || (rowCount == 0 && splits.isEmpty)) {
scanRowCount
} else {
OptionalLong.empty()
}
- } else if (splits.exists(_.rowCount() == -1)) {
- OptionalLong.empty()
} else {
- OptionalLong.of(splits.map(_.rowCount()).sum)
+ sumSplitRowCounts
+ }
+ }
+
+ private def sumSplitRowCounts: OptionalLong = {
+ var totalRowCount = 0L
+ var index = 0
+ while (index < splits.length) {
+ val rowCount = splits(index).rowCount()
+ if (rowCount <= 0) {
+ return OptionalLong.empty()
+ }
+ try {
+ totalRowCount = Math.addExact(totalRowCount, rowCount)
+ } catch {
+ case _: ArithmeticException => return OptionalLong.empty()
+ }
+ index += 1
}
+ OptionalLong.of(totalRowCount)
}
lazy val sizeInBytes: OptionalLong = {
diff --git
a/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/read/PaimonStatisticsTest.scala
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/read/PaimonStatisticsTest.scala
new file mode 100644
index 0000000000..837b917189
--- /dev/null
+++
b/paimon-spark/paimon-spark-common/src/test/scala/org/apache/paimon/spark/read/PaimonStatisticsTest.scala
@@ -0,0 +1,75 @@
+/*
+ * 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.paimon.spark.read
+
+import org.apache.paimon.table.source.Split
+import org.apache.paimon.types.{DataTypes, RowType}
+
+import org.scalatest.funsuite.AnyFunSuite
+
+import java.util.{Optional, OptionalLong}
+
+class PaimonStatisticsTest extends AnyFunSuite {
+
+ private val rowType = RowType.of(DataTypes.INT())
+
+ test("non-positive split row counts are unknown") {
+ Seq(Seq(0L), Seq(-2L), Seq(5L, 0L), Seq(5L, -2L)).foreach {
+ rowCounts => assert(!statistics(rowCounts: _*).numRows.isPresent,
rowCounts.toString())
+ }
+
+ assert(statistics(2L, 3L).numRows.getAsLong == 5L)
+ }
+
+ test("non-positive scan row counts are unknown for a non-empty scan") {
+ Seq(0L, -1L, -2L).foreach {
+ rowCount =>
+ val result = statistics(OptionalLong.of(rowCount), -1L)
+ assert(!result.numRows.isPresent, rowCount.toString)
+ }
+ }
+
+ test("empty scans have an exact zero row count") {
+ assert(statistics().numRows.getAsLong == 0L)
+ assert(statistics(OptionalLong.of(0L)).numRows.getAsLong == 0L)
+ }
+
+ test("positive scan row count takes priority over split sentinels") {
+ assert(statistics(OptionalLong.of(4L), -1L).numRows.getAsLong == 4L)
+ }
+
+ test("overflowed split row count is unknown") {
+ assert(!statistics(Long.MaxValue, 1L).numRows.isPresent)
+ }
+
+ private def statistics(rowCounts: Long*): PaimonStatistics =
+ statistics(OptionalLong.empty(), rowCounts: _*)
+
+ private def statistics(scanRowCount: OptionalLong, rowCounts: Long*):
PaimonStatistics = {
+ val splits = rowCounts.map {
+ count =>
+ new Split {
+ override def rowCount(): Long = count
+
+ override def mergedRowCount(): OptionalLong = OptionalLong.empty()
+ }
+ }.toArray
+ PaimonStatistics(splits, rowType, rowType, Optional.empty(), scanRowCount)
+ }
+}