This is an automated email from the ASF dual-hosted git repository.

danny0405 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 276d120b839a fix(flink): retain files with missing column statistics 
(#20295)
276d120b839a is described below

commit 276d120b839a3d7b6ec64bf32b9132fab9f88a6d
Author: Shuo Cheng <[email protected]>
AuthorDate: Wed Oct 14 12:57:32 2026 +0800

    fix(flink): retain files with missing column statistics (#20295)
    
    * fix(flink): retain files with missing column statistics
    
    Preserve unknown column statistics during transposition and retain 
candidates with missing statistics before evaluating predicates. Add probe, 
index expansion, and SQL integration regression coverage.
    
    * test(flink): cover incomplete statistics in partition pruning
    
    Document conservative file retention when another conjunct excludes the 
file, and verify partition candidates after expanding indexed columns.
---
 .../apache/hudi/source/prune/ColumnStatsProbe.java |   8 ++
 .../apache/hudi/source/stats/FileStatsIndex.java   |   7 +-
 .../hudi/source/prune/TestColumnStatsProbe.java    |  28 +++++
 .../hudi/source/stats/TestColumnStatsIndex.java    | 115 +++++++++++++++++++++
 .../apache/hudi/table/ITTestHoodieDataSource.java  |  35 +++++++
 5 files changed, 189 insertions(+), 4 deletions(-)

diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/prune/ColumnStatsProbe.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/prune/ColumnStatsProbe.java
index 5fbb9ff938fa..6769128cd93e 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/prune/ColumnStatsProbe.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/prune/ColumnStatsProbe.java
@@ -60,6 +60,14 @@ public class ColumnStatsProbe implements Serializable {
    * @return true if the index row should be considered as a candidate
    */
   public boolean test(RowData indexRow, RowType.RowField[] queryFields) {
+    // Retain candidates with missing column statistics rather than treating 
unknown values as nulls.
+    for (int i = 0; i < queryFields.length; i++) {
+      // The row starts with file name and value count, followed by (min, max, 
null count) per column.
+      // Skip the two leading fields and i triples, then use offset 2 to check 
this column's null count.
+      if (indexRow.isNullAt(2 + i * 3 + 2)) {
+        return true;
+      }
+    }
     Map<String, ColumnStats> columnStatsMap = convertColumnStats(indexRow, 
queryFields);
     for (ExpressionEvaluators.Evaluator evaluator : evaluators) {
       if (!evaluator.eval(columnStatsMap)) {
diff --git 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/stats/FileStatsIndex.java
 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/stats/FileStatsIndex.java
index 99c22402a02f..a18d52ff6a5d 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/stats/FileStatsIndex.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/source/stats/FileStatsIndex.java
@@ -306,10 +306,9 @@ public class FileStatsIndex implements ColumnStatsIndex {
 
       List<Tuple3> columnStats = 
alignedColumnRowsMap.values().stream().map(row -> {
         if (row == null) {
-          // NOTE: Since we're assuming missing column to essentially contain 
exclusively
-          //       null values, we set null-count to be equal to value-count 
(this behavior is
-          //       consistent with reading non-existent columns from Parquet)
-          return Tuple3.of(null, null, valueCount);
+          // Missing statistics do not imply all-null data. A null null-count 
marks
+          // unknown statistics so the probe can conservatively retain this 
candidate.
+          return Tuple3.of(null, null, null);
         } else {
           GenericRowData gr = (GenericRowData) row;
           return Tuple3.of(gr.getField(ORD_MIN_VAL), gr.getField(ORD_MAX_VAL), 
gr.getField(ORD_NULL_CNT));
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/prune/TestColumnStatsProbe.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/prune/TestColumnStatsProbe.java
index 01e302acc5ff..b11aec662eac 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/prune/TestColumnStatsProbe.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/prune/TestColumnStatsProbe.java
@@ -23,14 +23,21 @@ import org.apache.hudi.source.stats.ColumnStats;
 import org.apache.hudi.utils.TestData;
 
 import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.data.GenericRowData;
 import org.apache.flink.table.data.RowData;
 import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.expressions.CallExpression;
+import org.apache.flink.table.expressions.FieldReferenceExpression;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
 import org.apache.flink.table.types.DataType;
 import org.apache.flink.table.types.logical.RowType;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.Map;
 import java.util.stream.Stream;
 
@@ -54,6 +61,27 @@ class TestColumnStatsProbe {
     assertEquals(maxValue, stats1.get("field").getMaxVal());
   }
 
+  @ParameterizedTest
+  @MethodSource("columnStatsCompleteness")
+  void testProbeWithIncompleteColumnStats(Integer minValue, Integer maxValue, 
Long nullCount, boolean expected) {
+    CallExpression filter = 
CallExpression.permanent(BuiltInFunctionDefinitions.EQUALS,
+        Arrays.asList(new FieldReferenceExpression("field", DataTypes.INT(), 
0, 0), new ValueLiteralExpression(3)),
+        DataTypes.BOOLEAN());
+    ColumnStatsProbe probe = 
ColumnStatsProbe.newInstance(Collections.singletonList(filter));
+    RowData indexRow = GenericRowData.of(StringData.fromString("f1"), 10L, 
minValue, maxValue, nullCount);
+    RowType.RowField[] fields = 
getDataFields(getDataRowDataType(DataTypes.INT()));
+    assertEquals(expected, probe.test(indexRow, fields));
+  }
+
+  static Stream<Arguments> columnStatsCompleteness() {
+    return Stream.of(
+        arguments(null, null, null, true), // Missing statistics must retain 
the file.
+        arguments(null, null, 10L, false), // Known all-null data cannot match 
equality.
+        arguments(1, 5, 0L, true),
+        arguments(4, 5, 0L, false)
+    );
+  }
+
   static Stream<Arguments> testTypes() {
     return Stream.of(
         arguments(DataTypes.INT(), 1, 5),
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/stats/TestColumnStatsIndex.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/stats/TestColumnStatsIndex.java
index 02c6f9cea77b..59e0f8c26fe0 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/stats/TestColumnStatsIndex.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/source/stats/TestColumnStatsIndex.java
@@ -28,14 +28,22 @@ import org.apache.hudi.utils.TestConfigurations;
 import org.apache.hudi.utils.TestData;
 
 import org.apache.flink.configuration.Configuration;
+import org.apache.flink.table.api.DataTypes;
 import org.apache.flink.table.data.GenericRowData;
 import org.apache.flink.table.data.RowData;
+import org.apache.flink.table.data.StringData;
+import org.apache.flink.table.data.TimestampData;
+import org.apache.flink.table.expressions.CallExpression;
+import org.apache.flink.table.expressions.FieldReferenceExpression;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import java.io.File;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Comparator;
@@ -164,6 +172,113 @@ public class TestColumnStatsIndex {
     }
   }
 
+  @Test
+  void testCandidateFilesWithMissingColumnStatsAfterIndexExpansion() throws 
Exception {
+    final String path = tempFile.getAbsolutePath();
+    Configuration conf = TestConfigurations.getDefaultConf(path);
+    conf.set(FlinkOptions.TABLE_TYPE, "COPY_ON_WRITE");
+    conf.set(FlinkOptions.METADATA_ENABLED, true);
+    conf.set(FlinkOptions.READ_DATA_SKIPPING_ENABLED, true);
+    
conf.setString(HoodieMetadataConfig.ENABLE_METADATA_INDEX_COLUMN_STATS.key(), 
"true");
+    
conf.setString(HoodieMetadataConfig.ENABLE_METADATA_INDEX_PARTITION_STATS.key(),
 "false");
+    conf.setString(HoodieMetadataConfig.COLUMN_STATS_INDEX_FOR_COLUMNS.key(), 
"age");
+
+    // The old file contains a non-null name, but only age has column 
statistics.
+    TestData.writeData(Collections.singletonList(TestData.insertRow(
+        StringData.fromString("id1"), StringData.fromString("alice"), 25,
+        TimestampData.fromEpochMillis(1), StringData.fromString("par1"))), 
conf);
+
+    // Write a different partition so expanding the index does not rewrite the 
old file.
+    conf.setString(HoodieMetadataConfig.COLUMN_STATS_INDEX_FOR_COLUMNS.key(), 
"age,name");
+    TestData.writeData(Collections.singletonList(TestData.insertRow(
+        StringData.fromString("id2"), StringData.fromString("bob"), 30,
+        TimestampData.fromEpochMillis(2), StringData.fromString("par2"))), 
conf);
+
+    try (FileStatsIndex index = new FileStatsIndex(path, 
TestConfigurations.ROW_TYPE, conf, StreamerUtil.createMetaClient(conf))) {
+      String[] columns = {"age", "name"};
+      List<RowData> oldStats = index.readColumnStatsIndexByColumns(columns, 
Collections.singletonList("par1"));
+      assertEquals(1, oldStats.size());
+      assertEquals("age", 
oldStats.get(0).getString(ColumnStatsSchemas.ORD_COL_NAME).toString());
+      String oldFile = 
oldStats.get(0).getString(ColumnStatsSchemas.ORD_FILE_NAME).toString();
+
+      List<RowData> allStats = index.readColumnStatsIndexByColumns(columns, 
Collections.emptyList());
+      assertEquals(3, allStats.size());
+      List<String> allFiles = allStats.stream()
+          .map(row -> 
row.getString(ColumnStatsSchemas.ORD_FILE_NAME).toString()).distinct().collect(Collectors.toList());
+      assertEquals(2, allFiles.size());
+
+      CallExpression ageFilter = CallExpression.permanent(
+          BuiltInFunctionDefinitions.GREATER_THAN_OR_EQUAL,
+          Arrays.asList(new FieldReferenceExpression("age", DataTypes.INT(), 
0, 2), new ValueLiteralExpression(0)),
+          DataTypes.BOOLEAN());
+      CallExpression nameFilter = CallExpression.permanent(
+          BuiltInFunctionDefinitions.EQUALS,
+          Arrays.asList(new FieldReferenceExpression("name", 
DataTypes.STRING(), 0, 1), new ValueLiteralExpression("alice")),
+          DataTypes.BOOLEAN());
+
+      // With only name referenced, the old file is retained by the 
unindexed-file fallback.
+      assertEquals(Collections.singleton(oldFile), index.computeCandidateFiles(
+          ColumnStatsProbe.newInstance(Collections.singletonList(nameFilter)), 
new ArrayList<>(allFiles), Collections.emptyList()));
+
+      // Adding a matching age predicate must not turn missing name statistics 
into all-null data.
+      assertEquals(Collections.singleton(oldFile), index.computeCandidateFiles(
+          ColumnStatsProbe.newInstance(Arrays.asList(ageFilter, nameFilter)), 
new ArrayList<>(allFiles), Collections.emptyList()));
+
+      CallExpression nonMatchingAgeFilter = CallExpression.permanent(
+          BuiltInFunctionDefinitions.GREATER_THAN,
+          Arrays.asList(new FieldReferenceExpression("age", DataTypes.INT(), 
0, 2), new ValueLiteralExpression(100)),
+          DataTypes.BOOLEAN());
+      assertEquals(Collections.emptySet(), index.computeCandidateFiles(
+          
ColumnStatsProbe.newInstance(Collections.singletonList(nonMatchingAgeFilter)), 
new ArrayList<>(allFiles), Collections.emptyList()));
+      // Missing name statistics conservatively retain the old file even when 
age alone excludes it.
+      // The fully indexed new file can still be pruned.
+      assertEquals(Collections.singleton(oldFile), index.computeCandidateFiles(
+          ColumnStatsProbe.newInstance(Arrays.asList(nonMatchingAgeFilter, 
nameFilter)), new ArrayList<>(allFiles), Collections.emptyList()));
+    }
+  }
+
+  @Test
+  void testCandidatePartitionsWithMissingColumnStatsAfterIndexExpansion() 
throws Exception {
+    final String path = tempFile.getAbsolutePath();
+    Configuration conf = TestConfigurations.getDefaultConf(path);
+    conf.set(FlinkOptions.TABLE_TYPE, "COPY_ON_WRITE");
+    conf.set(FlinkOptions.METADATA_ENABLED, true);
+    conf.set(FlinkOptions.READ_DATA_SKIPPING_ENABLED, true);
+    
conf.setString(HoodieMetadataConfig.ENABLE_METADATA_INDEX_COLUMN_STATS.key(), 
"true");
+    
conf.setString(HoodieMetadataConfig.ENABLE_METADATA_INDEX_PARTITION_STATS.key(),
 "true");
+    conf.setString(HoodieMetadataConfig.COLUMN_STATS_INDEX_FOR_COLUMNS.key(), 
"age");
+    TestData.writeData(Collections.singletonList(TestData.insertRow(
+        StringData.fromString("id1"), StringData.fromString("alice"), 25,
+        TimestampData.fromEpochMillis(1), StringData.fromString("par1"))), 
conf);
+
+    conf.setString(HoodieMetadataConfig.COLUMN_STATS_INDEX_FOR_COLUMNS.key(), 
"age,name");
+    TestData.writeData(Collections.singletonList(TestData.insertRow(
+        StringData.fromString("id2"), StringData.fromString("bob"), 30,
+        TimestampData.fromEpochMillis(2), StringData.fromString("par2"))), 
conf);
+
+    try (PartitionStatsIndex index = new PartitionStatsIndex(path, 
TestConfigurations.ROW_TYPE, conf, StreamerUtil.createMetaClient(conf))) {
+      List<RowData> stats = index.readColumnStatsIndexByColumns(new String[] 
{"age", "name"}, Collections.emptyList());
+      List<String> actualStats = 
stats.stream().map(Object::toString).sorted().collect(Collectors.toList());
+      // Verify the old partition is partially indexed, rather than entirely 
absent from the index.
+      assertEquals(Arrays.asList(
+          "+I(par1,+I(25),+I(25),0,1,age)",
+          "+I(par2,+I(30),+I(30),0,1,age)",
+          "+I(par2,+I(bob),+I(bob),0,1,name)"), actualStats);
+
+      CallExpression ageFilter = CallExpression.permanent(
+          BuiltInFunctionDefinitions.GREATER_THAN_OR_EQUAL,
+          Arrays.asList(new FieldReferenceExpression("age", DataTypes.INT(), 
0, 2), new ValueLiteralExpression(0)),
+          DataTypes.BOOLEAN());
+      CallExpression nameFilter = CallExpression.permanent(
+          BuiltInFunctionDefinitions.EQUALS,
+          Arrays.asList(new FieldReferenceExpression("name", 
DataTypes.STRING(), 0, 1), new ValueLiteralExpression("alice")),
+          DataTypes.BOOLEAN());
+      // Partition pruning must retain the matching old partition before file 
pruning runs.
+      assertEquals(Collections.singleton("par1"), 
index.computeCandidatePartitions(
+          ColumnStatsProbe.newInstance(Arrays.asList(ageFilter, nameFilter)), 
new ArrayList<>(Arrays.asList("par1", "par2"))));
+    }
+  }
+
   private static List<RowData> filterOutFileNames(List<RowData> indexRows) {
     return indexRows.stream().map(row -> {
       GenericRowData gr = (GenericRowData) row;
diff --git 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
index ef017d7ed088..69b22e266b6a 100644
--- 
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
+++ 
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/table/ITTestHoodieDataSource.java
@@ -2942,6 +2942,41 @@ public class ITTestHoodieDataSource {
             + "+I[id3, id3, Julian, 43, 1970-01-01T00:00:03, par1]]");
   }
 
+  @Test
+  void testDataSkippingWithMissingColumnStatsAfterIndexExpansion() throws 
Exception {
+    TableEnvironment tableEnv = batchTableEnv;
+    String hoodieTableDDL = sql("t1")
+        .option(FlinkOptions.PATH, tempFile.getAbsolutePath())
+        .options(getDefaultKeys())
+        .option(FlinkOptions.TABLE_TYPE, COPY_ON_WRITE)
+        .option(FlinkOptions.METADATA_ENABLED, true)
+        .option(HoodieMetadataConfig.ENABLE_METADATA_INDEX_COLUMN_STATS.key(), 
true)
+        
.option(HoodieMetadataConfig.ENABLE_METADATA_INDEX_PARTITION_STATS.key(), false)
+        .option(HoodieMetadataConfig.COLUMN_STATS_INDEX_FOR_COLUMNS.key(), 
"age")
+        .option(FlinkOptions.READ_DATA_SKIPPING_ENABLED, false)
+        .end();
+    tableEnv.executeSql(hoodieTableDDL);
+
+    // The name column exists in the old data file, but only age is indexed.
+    tableEnv.executeSql("insert into t1 values "
+        + "('id1', 'alice', 25, TIMESTAMP '1970-01-01 00:00:01', 
'par1')").await();
+
+    tableEnv.executeSql("alter table t1 set 
('hoodie.metadata.index.column.stats.column.list' = 'age,name')");
+    // A new partition leaves the old file and its incomplete column 
statistics untouched.
+    tableEnv.executeSql("insert into t1 values "
+        + "('id2', 'bob', 30, TIMESTAMP '1970-01-01 00:00:02', 
'par2')").await();
+
+    String query = "select * from t1 where age >= 0 and name = 'alice'";
+    String expected = "[+I[id1, alice, 25, 1970-01-01T00:00:01, par1]]";
+    List<Row> withoutSkipping = execSelectSql(tableEnv, query);
+    assertRowsEquals(withoutSkipping, expected);
+
+    tableEnv.executeSql("alter table t1 set ('read.data.skipping.enabled' = 
'true')");
+    assertRowsEquals(execSelectSql(tableEnv, "select * from t1 where name = 
'alice'"), expected);
+    // Adding a matching age predicate must not cause the old file to be 
skipped.
+    assertRowsEquals(execSelectSql(tableEnv, query), expected);
+  }
+
   @ParameterizedTest
   @EnumSource(value = HoodieTableType.class)
   void testDataSkippingByFilteringFileSlice(HoodieTableType tableType) {

Reply via email to