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 53f0a24d5f [api][spark] Treat an unreported partition statistic as 
unknown, not as zero (#9379)
53f0a24d5f is described below

commit 53f0a24d5f180f1516fd0c34ddec79d1c5dc9045
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Tue Aug 25 08:49:45 2026 +0800

    [api][spark] Treat an unreported partition statistic as unknown, not as 
zero (#9379)
---
 .../org/apache/paimon/partition/Partition.java     | 63 +++++++++++++++++-----
 .../org/apache/paimon/partition/PartitionTest.java | 59 ++++++++++++++++++++
 .../format/CatalogManagedPartitionScanTest.java    | 26 +++++++++
 .../paimon/spark/read/PaimonStatistics.scala       | 25 +++++----
 .../sql/CatalogManagedPartitionAnalyzeTest.scala   | 39 ++++++++++++++
 5 files changed, 190 insertions(+), 22 deletions(-)

diff --git 
a/paimon-api/src/main/java/org/apache/paimon/partition/Partition.java 
b/paimon-api/src/main/java/org/apache/paimon/partition/Partition.java
index c9fa4e0997..ad8d0d5cc7 100644
--- a/paimon-api/src/main/java/org/apache/paimon/partition/Partition.java
+++ b/paimon-api/src/main/java/org/apache/paimon/partition/Partition.java
@@ -74,20 +74,19 @@ public class Partition extends PartitionStatistics {
     @Nullable
     private final Map<String, String> options;
 
-    @JsonCreator
     public Partition(
-            @JsonProperty(FIELD_SPEC) Map<String, String> spec,
-            @JsonProperty(FIELD_RECORD_COUNT) long recordCount,
-            @JsonProperty(FIELD_FILE_SIZE_IN_BYTES) long fileSizeInBytes,
-            @JsonProperty(FIELD_FILE_COUNT) long fileCount,
-            @JsonProperty(FIELD_LAST_FILE_CREATION_TIME) long 
lastFileCreationTime,
-            @JsonProperty(FIELD_TOTAL_BUCKETS) int totalBuckets,
-            @JsonProperty(FIELD_DONE) boolean done,
-            @JsonProperty(FIELD_CREATED_AT) @Nullable Long createdAt,
-            @JsonProperty(FIELD_CREATED_BY) @Nullable String createdBy,
-            @JsonProperty(FIELD_UPDATED_AT) @Nullable Long updatedAt,
-            @JsonProperty(FIELD_UPDATED_BY) @Nullable String updatedBy,
-            @JsonProperty(FIELD_OPTIONS) @Nullable Map<String, String> 
options) {
+            Map<String, String> spec,
+            long recordCount,
+            long fileSizeInBytes,
+            long fileCount,
+            long lastFileCreationTime,
+            int totalBuckets,
+            boolean done,
+            @Nullable Long createdAt,
+            @Nullable String createdBy,
+            @Nullable Long updatedAt,
+            @Nullable String updatedBy,
+            @Nullable Map<String, String> options) {
         super(spec, recordCount, fileSizeInBytes, fileCount, 
lastFileCreationTime, totalBuckets);
         this.done = done;
         this.createdAt = createdAt;
@@ -120,6 +119,44 @@ public class Partition extends PartitionStatistics {
                 null);
     }
 
+    /**
+     * Reads a partition off the wire. The statistics are optional in the REST 
contract, so an
+     * absent one decodes to {@link #UNKNOWN} rather than to {@code 0}. {@code 
totalBuckets} keeps
+     * its {@code 0} default, for writers older than that field.
+     */
+    @JsonCreator
+    static Partition fromJson(
+            @JsonProperty(FIELD_SPEC) Map<String, String> spec,
+            @JsonProperty(FIELD_RECORD_COUNT) @Nullable Long recordCount,
+            @JsonProperty(FIELD_FILE_SIZE_IN_BYTES) @Nullable Long 
fileSizeInBytes,
+            @JsonProperty(FIELD_FILE_COUNT) @Nullable Long fileCount,
+            @JsonProperty(FIELD_LAST_FILE_CREATION_TIME) @Nullable Long 
lastFileCreationTime,
+            @JsonProperty(FIELD_TOTAL_BUCKETS) int totalBuckets,
+            @JsonProperty(FIELD_DONE) boolean done,
+            @JsonProperty(FIELD_CREATED_AT) @Nullable Long createdAt,
+            @JsonProperty(FIELD_CREATED_BY) @Nullable String createdBy,
+            @JsonProperty(FIELD_UPDATED_AT) @Nullable Long updatedAt,
+            @JsonProperty(FIELD_UPDATED_BY) @Nullable String updatedBy,
+            @JsonProperty(FIELD_OPTIONS) @Nullable Map<String, String> 
options) {
+        return new Partition(
+                spec,
+                orUnknown(recordCount),
+                orUnknown(fileSizeInBytes),
+                orUnknown(fileCount),
+                orUnknown(lastFileCreationTime),
+                totalBuckets,
+                done,
+                createdAt,
+                createdBy,
+                updatedAt,
+                updatedBy,
+                options);
+    }
+
+    private static long orUnknown(@Nullable Long value) {
+        return value == null ? UNKNOWN : value;
+    }
+
     @JsonGetter(FIELD_DONE)
     public boolean done() {
         return done;
diff --git 
a/paimon-api/src/test/java/org/apache/paimon/partition/PartitionTest.java 
b/paimon-api/src/test/java/org/apache/paimon/partition/PartitionTest.java
index b589a75cf1..ce514caae9 100644
--- a/paimon-api/src/test/java/org/apache/paimon/partition/PartitionTest.java
+++ b/paimon-api/src/test/java/org/apache/paimon/partition/PartitionTest.java
@@ -61,6 +61,65 @@ class PartitionTest {
         assertThat(json).contains("totalBuckets");
     }
 
+    @Test
+    void testAbsentStatisticsAreUnknownNotZero() {
+        // What listPartitions returns from a catalog that stores no 
statistics. Every consumer of
+        // this response reads the numbers through 
PartitionStatistics.isKnown, so absence has to
+        // arrive as unknown and not as an exact zero.
+        String statisticsFreeJson = "{\"spec\":{\"pt\":\"1\"},\"done\":true}";
+
+        Partition partition = JsonSerdeUtil.fromJson(statisticsFreeJson, 
Partition.class);
+
+        assertThat(partition.spec()).containsEntry("pt", "1");
+        assertThat(partition.done()).isTrue();
+        
assertThat(partition.recordCount()).isEqualTo(PartitionStatistics.UNKNOWN);
+        
assertThat(partition.fileSizeInBytes()).isEqualTo(PartitionStatistics.UNKNOWN);
+        
assertThat(partition.fileCount()).isEqualTo(PartitionStatistics.UNKNOWN);
+        
assertThat(partition.lastFileCreationTime()).isEqualTo(PartitionStatistics.UNKNOWN);
+        assertThat(partition.createdAt()).isNull();
+        assertThat(partition.options()).isNull();
+    }
+
+    @Test
+    void testReportedZeroStaysAnExactMeasurement() {
+        // The other half of the same boundary: a partition someone measured 
as empty must not come
+        // back as unknown.
+        String emptyPartitionJson =
+                
"{\"spec\":{\"pt\":\"1\"},\"recordCount\":0,\"fileSizeInBytes\":0,"
+                        + "\"fileCount\":0,\"lastFileCreationTime\":0}";
+
+        Partition partition = JsonSerdeUtil.fromJson(emptyPartitionJson, 
Partition.class);
+
+        assertThat(partition.recordCount()).isEqualTo(0L);
+        assertThat(partition.fileSizeInBytes()).isEqualTo(0L);
+        assertThat(partition.fileCount()).isEqualTo(0L);
+        
assertThat(PartitionStatistics.isKnown(partition.recordCount())).isTrue();
+    }
+
+    @Test
+    void testMeasurementsSurviveARoundTrip() {
+        Partition partition =
+                new Partition(
+                        Collections.singletonMap("pt", "1"),
+                        0L, // an empty partition someone did measure
+                        1024L,
+                        2L,
+                        1234567890L,
+                        10,
+                        true,
+                        1234567890L,
+                        "user1",
+                        1234567900L,
+                        "user2",
+                        Collections.singletonMap("key", "value"));
+
+        Partition parsed =
+                JsonSerdeUtil.fromJson(JsonSerdeUtil.toFlatJson(partition), 
Partition.class);
+
+        assertThat(parsed).isEqualTo(partition);
+        assertThat(parsed.recordCount()).isEqualTo(0L);
+    }
+
     @Test
     void testJsonSerializationWithNonNullValues() {
         Map<String, String> spec = Collections.singletonMap("pt", "1");
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 5ce899694d..2563a54b35 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
@@ -37,6 +37,7 @@ import org.apache.paimon.table.FormatTable;
 import org.apache.paimon.table.source.Split;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.JsonSerdeUtil;
 import org.apache.paimon.utils.PartitionPathUtils;
 
 import org.junit.jupiter.api.DisplayName;
@@ -206,6 +207,31 @@ class CatalogManagedPartitionScanTest {
         verify(catalog).listPartitionsPaged(IDENTIFIER, 1000, null, null);
     }
 
+    @Test
+    void testPlanRowCountStaysUnknownWhenCatalogReportsNoStatistics() throws 
Exception {
+        // Partitions as they come off the wire from a catalog that stores no 
statistics. Summing
+        // them as zeros would tell Spark the table is empty and get a huge 
scan broadcast.
+        Catalog catalog = mock(Catalog.class);
+        Partition october =
+                JsonSerdeUtil.fromJson(
+                        "{\"spec\":{\"year\":\"2025\",\"month\":\"10\"}}", 
Partition.class);
+        Partition november =
+                JsonSerdeUtil.fromJson(
+                        "{\"spec\":{\"year\":\"2025\",\"month\":\"11\"}}", 
Partition.class);
+        when(catalog.listPartitionsPaged(eq(IDENTIFIER), eq(1000), isNull(), 
isNull()))
+                .thenReturn(new PagedList<>(Arrays.asList(october, november), 
null));
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path tablePath = new Path(tempDir.toUri());
+        writeDataFile(fileIO, tablePath, "year=2025/month=10");
+        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(2);
+        assertThat(plan.rowCount()).isEqualTo(OptionalLong.empty());
+    }
+
     @Test
     void testUnderscoreInPartitionNameRemainsLiteralPrefix() {
         LocalFileIO fileIO = LocalFileIO.create();
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 9b06223aee..9df3e683b4 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
@@ -43,9 +43,17 @@ case class PaimonStatistics(
     scanRowCount: OptionalLong = OptionalLong.empty()
 ) extends Statistics {
 
+  private lazy val fileTotalSize: Long = splits.map(SplitUtils.splitSize).sum
+
   lazy val numRows: OptionalLong = {
     if (scanRowCount.isPresent) {
-      scanRowCount
+      // 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) {
+        scanRowCount
+      } else {
+        OptionalLong.empty()
+      }
     } else if (splits.exists(_.rowCount() == -1)) {
       OptionalLong.empty()
     } else {
@@ -54,20 +62,19 @@ case class PaimonStatistics(
   }
 
   lazy val sizeInBytes: OptionalLong = {
-    if (numRows.isPresent) {
+    if (numRows.isPresent && numRows.getAsLong > 0) {
       val sizeInBytes = numRows.getAsLong * estimateRowSize(readRowType)
       // Avoid return 0 bytes if there are some valid rows.
       // Avoid return too small size in bytes which may less than row count,
       // note the compression ratio on disk is usually bigger than memory.
       OptionalLong.of(Math.max(sizeInBytes, numRows.getAsLong))
+    } else if (fileTotalSize > 0) {
+      // Zero rows times any row size is zero, so weigh the files instead.
+      OptionalLong.of((fileTotalSize * readRowSizeRatio).toLong)
+    } else if (numRows.isPresent) {
+      OptionalLong.of(0L)
     } else {
-      val fileTotalSize = splits.map(SplitUtils.splitSize).sum
-      if (fileTotalSize == 0) {
-        OptionalLong.empty()
-      } else {
-        val size = (fileTotalSize * readRowSizeRatio).toLong
-        OptionalLong.of(size)
-      }
+      OptionalLong.empty()
     }
   }
 
diff --git 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionAnalyzeTest.scala
 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionAnalyzeTest.scala
index cd3193ab42..e2e85ae64e 100644
--- 
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionAnalyzeTest.scala
+++ 
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionAnalyzeTest.scala
@@ -376,6 +376,45 @@ class CatalogManagedPartitionAnalyzeTest extends 
PaimonSparkTestWithRestCatalogB
     }
   }
 
+  test("a zero row count does not make a partition that still has files look 
free to read") {
+    val tableName = "analyze_zero_row_count_size"
+    withTable(tableName) {
+      createTable(tableName)
+      sql(
+        s"INSERT INTO ${qualified(tableName)} VALUES " +
+          s"(1, 'a', '20260101', '00'), (2, 'b', '20260101', '00')")
+
+      // Positive control: a real measurement gives both a row count and a 
size.
+      val measured = getFormatTableScan(s"SELECT * FROM 
${qualified(tableName)}").estimateStatistics
+      assert(measured.numRows().getAsLong == 2L)
+      assert(measured.sizeInBytes().getAsLong > 0L)
+
+      // Rewrite the statistics to zero without touching the files, the way a 
catalog that cannot
+      // tell "never measured" from "measured, and empty" answers.
+      val spec = Map("dt" -> "20260101", "hour" -> "00").asJava
+      paimonCatalog.createPartitions(
+        Identifier.create(dbName0, tableName),
+        List(spec).asJava,
+        true,
+        List(
+          new PartitionStatistics(
+            spec,
+            0L,
+            0L,
+            0L,
+            0L,
+            PartitionStatistics.UNKNOWN_TOTAL_BUCKETS)).asJava,
+        true
+      )
+
+      val zeroed = getFormatTableScan(s"SELECT * FROM 
${qualified(tableName)}").estimateStatistics
+      // Neither number may say the scan is free: the size drives broadcast, 
the row count drives
+      // join reordering.
+      assert(!zeroed.numRows().isPresent, zeroed.numRows().toString)
+      assert(zeroed.sizeInBytes().getAsLong > 0L, 
zeroed.sizeInBytes().toString)
+    }
+  }
+
   test("partition row count is not duplicated across format data splits") {
     val tableName = "analyze_multi_split_statistics"
     withTable(tableName) {

Reply via email to