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

924060929 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 f823bfde17c [test](fe) Pin the partition values and null flags of 
external file ranges (#69001)
f823bfde17c is described below

commit f823bfde17c25b4e89bc82ed5fdef1af3ab13e6e
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Wed Oct 14 09:29:38 2026 +0800

    [test](fe) Pin the partition values and null flags of external file ranges 
(#69001)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #66012, #62821, #68124, #68786
    
    Problem Summary:
    
    **In short.** This PR ports to master the tests of branch-4.1 #66012
    whose behavior master already has, so that behavior is pinned. It
    changes no production code. #66012 made external scans and file loads
    send each file's partition values with explicit NULL flags; on master
    the same behavior arrived through #62821 and the connector plugins, but
    nothing tests three parts of it.
    
    **Background.** A file scan range tells BE the partition values of its
    file in `columns_from_path_keys`, `columns_from_path` and
    `columns_from_path_is_null`, and BE fills the partition columns from
    them instead of reading them from the file. Two producers matter here:
    
    - `FilePartitionUtils.parseColumnsFromPathWithNullInfo` parses
    `key=value` directories for Hive-style tables, TVFs and file loads. The
    Hive default partition (`__HIVE_DEFAULT_PARTITION__`) becomes SQL NULL
    through the null flag, and a literal `\N` stays data.
    - The Iceberg connector sends a file's identity partition values from
    the file's own partition spec and partition data, not from its path.
    After partition evolution, files of different specs carry different
    keys.
    
    **The problem, and what it cost.** These are the parts of #66012's tests
    that hold on master and that no master test covers. Each of them decides
    what BE writes into a partition column:
    
    | Behavior | What a regression would do |
    |---|---|
    | A case-insensitive parse lowercases a copy of the keys, not the
    caller's list | The caller's column list changes under it |
    | `__HIVE_DEFAULT_PARTITION__` → `""` with the null flag; a literal `\N`
    → `\N` without it; an ACID layout skips its delta directory | A NULL
    partition reads as an empty string, or a `\N` value reads as NULL |
    | An Iceberg range sends its identity values in spec order, a NULL value
    through the null flag | Values land in the wrong columns, or a NULL
    partition reads as an empty string |
    | After the spec evolves from (Region, Dt) to (Dt, Category), a file of
    each spec sends its own spec's keys | Files of one spec get the other
    spec's columns, which BE then fills as NULL |
    
    **How this PR fixes it.** It adds three tests:
    
    - `BrokerUtilTest#parseColumnsFromPathPreservesNullMetadataAndInputKeys`
    (#66012's test of the same name, calling `FilePartitionUtils`).
    -
    
`IcebergScanPlanProviderTest#planScanSendsIdentityPartitionsInSpecOrderWithNullFlags`
    (from #66012's
    `IcebergScanNodeTest#testSetPartitionValuesBuildsStableAlignedMetadata`).
    -
    
`IcebergScanPlanProviderTest#planScanSendsEachFilesOwnIdentityPartitionsAfterSpecEvolution`
    (from `#testSetPartitionValuesUsesPerSpecMetadataWithFileScannerV2`; it
    also checks the old-spec file). The Iceberg tests plan a scan of an
    in-memory table, as the neighboring #68124 test does.
    
    #66012 changes six more tests that are not ported, because master
    differs on purpose:
    
    | branch-4.x test | Why it does not apply to master |
    |---|---|
    | `BrokerUtilTest#normalizeColumnsFromPathPreservesNullInfo` | The
    connectors map the Hive default partition to NULL themselves
    (`HiveScanRangePartitionValuesTest`), so fe-core's normalization only
    maps a Java null |
    |
    
`IcebergUtilsTest#testGetCommonIdentityPartitionColumnsUsesSafeIntersection`,
    
`IcebergScanNodeTest#testSetPartitionValuesKeepsCommonMetadataWithLegacyFileScanner`
    | branch-4.x keeps only the identity columns every spec shares; master
    keeps the union, so a file written before the spec evolved to
    unpartitioned keeps its values (#68124) |
    | `IcebergUtilsTest#testMappedTypesAreExcludedFromPartitionMetadata`,
    
`IcebergScanNodeTest#testSetPartitionValuesSkipsValuesUnsupportedByMappedTypes`
    | Since #68786, master sends UUID and TIMESTAMPTZ identity values too |
    | `BrokerUtilTest#parseColumnsFromPathForLoadKeepsLegacyNullSemantics` |
    A real difference: on branch-4.x a file load stores a literal `\N` path
    value as NULL, master stores the two characters `\N`. It needs a code
    change and comes in a separate PR |
    
    **Results.** The three tests pass on master without any code change, and
    each catches a regression of what it pins:
    
    | Change to master | Fails |
    |---|---|
    | `FilePartitionUtils` stops flagging the Hive default partition as NULL
    | `parseColumnsFromPathPreservesNullMetadataAndInputKeys` |
    | `FilePartitionUtils` lowercases the caller's key list in place |
    `parseColumnsFromPathPreservesNullMetadataAndInputKeys` |
    | `IcebergScanRange` sends a NULL identity value without the null flag |
    both new Iceberg tests |
    | The Iceberg planner sends every identity key of every spec for each
    file | `planScanSendsEachFilesOwnIdentityPartitionsAfterSpecEvolution`
    (and the existing
    `planScanKeepsUnpartitionedSpecIdentityAfterEvolvingToPartitioned`) |
    
    Original author: @suxiaogang223
    
    ---------
    
    Co-authored-by: Socrates <[email protected]>
---
 .../iceberg/IcebergScanPlanProviderTest.java       | 67 ++++++++++++++++++++++
 .../apache/doris/common/util/BrokerUtilTest.java   | 26 +++++++++
 2 files changed, 93 insertions(+)

diff --git 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
index e83a3049fde..8654cc569de 100644
--- 
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
+++ 
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergScanPlanProviderTest.java
@@ -57,6 +57,7 @@ import org.apache.iceberg.FileScanTask;
 import org.apache.iceberg.ManifestFile;
 import org.apache.iceberg.MetadataColumns;
 import org.apache.iceberg.Metrics;
+import org.apache.iceberg.PartitionData;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.Snapshot;
@@ -1016,6 +1017,72 @@ public class IcebergScanPlanProviderTest {
         Assertions.assertEquals(OptionalLong.empty(), 
provider.scannedPartitionCount(ranges));
     }
 
+    @Test
+    public void planScanSendsIdentityPartitionsInSpecOrderWithNullFlags() {
+        // From branch-4.x #66012: a range carries its identity partition 
values keyed and ordered by the partition
+        // spec, and a NULL value travels as an explicit null flag, not as a 
string.
+        Schema schema = new Schema(
+                Types.NestedField.optional(1, "Region", 
Types.StringType.get()),
+                Types.NestedField.optional(2, "Dt", Types.StringType.get()));
+        PartitionSpec spec = 
PartitionSpec.builderFor(schema).identity("Region").identity("Dt").build();
+        Table table = createTable("aligned", schema, spec);
+        table.newAppend().appendFile(partitionedFile(spec, 
"s3://b/db/aligned/a.parquet", "cn", null)).commit();
+
+        IcebergScanPlanProvider provider = new IcebergScanPlanProvider(
+                IcebergCatalogProperties.of(Collections.emptyMap()), 
opsReturning(table));
+        List<ConnectorScanRange> ranges = provider.planScan(new 
FakeScanSession("UTC", Collections.emptyMap()),
+                ConnectorScanRequest.builder(new IcebergTableHandle("db1", 
"aligned"), Collections.emptyList())
+                .build());
+
+        Assertions.assertEquals(1, ranges.size());
+        TFileRangeDesc desc = populate(ranges.get(0));
+        Assertions.assertEquals(Arrays.asList("Region", "Dt"), 
desc.getColumnsFromPathKeys());
+        Assertions.assertEquals(Arrays.asList("cn", ""), 
desc.getColumnsFromPath());
+        Assertions.assertEquals(Arrays.asList(false, true), 
desc.getColumnsFromPathIsNull());
+    }
+
+    @Test
+    public void 
planScanSendsEachFilesOwnIdentityPartitionsAfterSpecEvolution() {
+        // From branch-4.x #66012: after the spec evolves from (Region, Dt) to 
(Dt, Category), a file of either spec
+        // carries the identity values of its own spec, in that spec's order.
+        Schema schema = new Schema(
+                Types.NestedField.optional(1, "Region", 
Types.StringType.get()),
+                Types.NestedField.optional(2, "Dt", Types.StringType.get()),
+                Types.NestedField.optional(3, "Category", 
Types.StringType.get()));
+        PartitionSpec oldSpec = 
PartitionSpec.builderFor(schema).identity("Region").identity("Dt").build();
+        Table table = createTable("evolved", schema, oldSpec, 
Collections.singletonMap("format-version", "2"));
+        table.newAppend().appendFile(partitionedFile(oldSpec, 
"s3://b/db/evolved/old.parquet", "us", "2024-01-01"))
+                .commit();
+        table.updateSpec().removeField("Region").addField("Category").commit();
+        table.newAppend().appendFile(partitionedFile(table.spec(), 
"s3://b/db/evolved/new.parquet", null, "books"))
+                .commit();
+
+        IcebergScanPlanProvider provider = new IcebergScanPlanProvider(
+                IcebergCatalogProperties.of(Collections.emptyMap()), 
opsReturning(table));
+        List<ConnectorScanRange> ranges = provider.planScan(new 
FakeScanSession("UTC", Collections.emptyMap()),
+                ConnectorScanRequest.builder(new IcebergTableHandle("db1", 
"evolved"), Collections.emptyList())
+                .build());
+
+        TFileRangeDesc oldDesc = populate(byPath(ranges, "old.parquet"));
+        Assertions.assertEquals(Arrays.asList("Region", "Dt"), 
oldDesc.getColumnsFromPathKeys());
+        Assertions.assertEquals(Arrays.asList("us", "2024-01-01"), 
oldDesc.getColumnsFromPath());
+        Assertions.assertEquals(Arrays.asList(false, false), 
oldDesc.getColumnsFromPathIsNull());
+        TFileRangeDesc newDesc = populate(byPath(ranges, "new.parquet"));
+        Assertions.assertEquals(Arrays.asList("Dt", "Category"), 
newDesc.getColumnsFromPathKeys());
+        Assertions.assertEquals(Arrays.asList("", "books"), 
newDesc.getColumnsFromPath());
+        Assertions.assertEquals(Arrays.asList(true, false), 
newDesc.getColumnsFromPathIsNull());
+    }
+
+    /** A data file whose partition values, in the spec's field order, may be 
null. */
+    private static DataFile partitionedFile(PartitionSpec spec, String path, 
String... values) {
+        PartitionData data = new PartitionData(spec.partitionType());
+        for (int i = 0; i < values.length; i++) {
+            data.set(i, values[i]);
+        }
+        return 
DataFiles.builder(spec).withPath(path).withFormat(FileFormat.PARQUET).withPartition(data)
+                .withFileSizeInBytes(1024).withRecordCount(1).build();
+    }
+
     @Test
     public void 
planScanCountsScannedPartitionsWhileCurrentSpecStaysPartitioned() {
         // The other side of the display gate: with a partitioned CURRENT 
spec, files of an older spec keep
diff --git 
a/fe/fe-core/src/test/java/org/apache/doris/common/util/BrokerUtilTest.java 
b/fe/fe-core/src/test/java/org/apache/doris/common/util/BrokerUtilTest.java
index 25fa2206182..5e1d286fc84 100644
--- a/fe/fe-core/src/test/java/org/apache/doris/common/util/BrokerUtilTest.java
+++ b/fe/fe-core/src/test/java/org/apache/doris/common/util/BrokerUtilTest.java
@@ -18,6 +18,7 @@
 package org.apache.doris.common.util;
 
 import org.apache.doris.common.UserException;
+import org.apache.doris.connector.spi.scan.ConnectorPartitionValues;
 import org.apache.doris.datasource.scan.FilePartitionUtils;
 
 import com.google.common.collect.Lists;
@@ -125,4 +126,29 @@ public class BrokerUtilTest {
 
     }
 
+    @Test
+    public void parseColumnsFromPathPreservesNullMetadataAndInputKeys() throws 
Exception {
+        // A case-insensitive parse lowercases its own copy of the keys, not 
the caller's list, and turns the Hive
+        // default partition into SQL NULL.
+        List<String> partitionKeys = Lists.newArrayList("Region", "Dt");
+        FilePartitionUtils.ParsedColumnsFromPath parsed = 
FilePartitionUtils.parseColumnsFromPathWithNullInfo(
+                "hdfs://host/table/REGION=cn/dt=" + 
ConnectorPartitionValues.NULL_PARTITION_NAME + "/data.parquet",
+                partitionKeys, false, false);
+        Assertions.assertEquals(Lists.newArrayList("cn", ""), 
parsed.getValues());
+        Assertions.assertEquals(Lists.newArrayList(false, true), 
parsed.getIsNull());
+        Assertions.assertEquals(Lists.newArrayList("Region", "Dt"), 
partitionKeys);
+
+        // A literal \N is data on the query path.
+        parsed = FilePartitionUtils.parseColumnsFromPathWithNullInfo(
+                "hdfs://host/table/p=\\N/data.orc", 
Collections.singletonList("p"), true, false);
+        Assertions.assertEquals(Collections.singletonList("\\N"), 
parsed.getValues());
+        Assertions.assertEquals(Collections.singletonList(false), 
parsed.getIsNull());
+
+        // An ACID layout has one more directory level between the partition 
and the file.
+        parsed = FilePartitionUtils.parseColumnsFromPathWithNullInfo(
+                "hdfs://host/table/p=value/delta_1_1/bucket_00000", 
Collections.singletonList("p"), true, true);
+        Assertions.assertEquals(Collections.singletonList("value"), 
parsed.getValues());
+        Assertions.assertEquals(Collections.singletonList(false), 
parsed.getIsNull());
+    }
+
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to