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

Gabriel39 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 3b81a1eeaa9 [fix](hudi) Decode Spark SQL timestamp partition paths 
(#69157)
3b81a1eeaa9 is described below

commit 3b81a1eeaa9e5d94eae3714b353796193df83eb9
Author: Gabriel <[email protected]>
AuthorDate: Fri Oct 16 16:02:38 2026 +0800

    [fix](hudi) Decode Spark SQL timestamp partition paths (#69157)
    
    ### What problem does this PR solve?
    
    Spark SQL's Hudi `SqlKeyGenerator` always escapes formatted TIMESTAMP
    partition values, independently of
    `hoodie.table.url.encode.partitioning`. Doris currently unescapes
    partition values only when that general flag is enabled. With the flag
    absent or false, a physical component such as `event_time=2024-01-01
    10%3A00%3A00` reaches the BE as an invalid timestamp instead of
    `2024-01-01 10:00:00`. This breaks `test_hudi_partition_prune` and
    `test_hudi_runtime_filter_partition_pruning`.
    
    Carry timestamp partition-column names from the table schema through
    Hudi handles and apply exactly one Hive-style unescape to those logical
    values. Use the same interpretation for HMS/Hudi metadata listing,
    partition pruning, snapshot pin filtering, and COW/MOR snapshot and
    incremental scan ranges. Preserve physical file paths. Other types still
    follow the persisted URL-encoding flag, so a STRING containing literal
    `%3A`, `%253A`, or `+` remains distinct and unchanged when encoding is
    disabled.
    
    The writer behavior is documented in [Hudi's
    
SqlKeyGenerator](https://github.com/apache/hudi/blob/release-1.0.2/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/SqlKeyGenerator.scala).
    
    ### Validation
    
    - Confirmed the new metadata regression fails before the fix with
    expected `10:00:00`, actual `10%3A00%3A00`.
    - `mvn -f fe/pom.xml -pl :fe-connector-hudi -am test
    -Dmaven.build.cache.enabled=false`: 1,007 tests passed, including 270
    Hudi tests; zero failures/errors/skips.
    - FE Checkstyle/validate and Groovy syntax compilation passed.
    - Coverage includes real persisted SDK configurations with
    missing/false/true encoding flags, mixed timestamp/STRING partitions,
    single-layer decoding, nulls, HMS and metadata pruning, historical
    listing, a real local COW commit/write-stat mapping, and MOR native/JNI
    Thrift values.
    - Extended the existing external regression to compare native/JNI
    timestamp rows under both partition-listing modes while keeping the
    existing golden assertion and restoring the session setting.
    
    The real Spark writer and full external Doris/HMS/Hudi suites were not
    run locally; the new external assertion is syntax-checked and remains
    for CI.
    
    ### Release note
    
    Fix reads of Spark SQL Hudi TIMESTAMP partitions whose paths are escaped
    while generic URL partition encoding is disabled.
    
    ### Check List (For Author)
    
    - [x] Unit Test
    - [x] Regression test extension
    - [x] Behavior changed: timestamp partition values are decoded according
    to Spark/Hudi writer semantics
    - [x] No documentation needed
---
 .../connector/hudi/COWIncrementalRelation.java     |   7 +-
 .../connector/hudi/HudiConnectorMetadata.java      |  32 +++++--
 .../doris/connector/hudi/HudiScanPlanProvider.java |  44 +++++++--
 .../doris/connector/hudi/HudiTableHandle.java      |  15 +++
 .../hudi/HudiConnectorPartitionListingTest.java    | 104 ++++++++++++++++++++-
 .../hudi/HudiIncrementalPlanScanTest.java          |  91 +++++++++++++++---
 .../connector/hudi/HudiPartitionValuesTest.java    |  29 ++++++
 .../hudi/test_hudi_partition_prune.groovy          |  12 +++
 8 files changed, 303 insertions(+), 31 deletions(-)

diff --git 
a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/COWIncrementalRelation.java
 
b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/COWIncrementalRelation.java
index 71566952746..1430a9a9288 100644
--- 
a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/COWIncrementalRelation.java
+++ 
b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/COWIncrementalRelation.java
@@ -42,6 +42,7 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Consumer;
 import java.util.function.UnaryOperator;
 import java.util.stream.Collectors;
@@ -59,6 +60,7 @@ import java.util.stream.Collectors;
 final class COWIncrementalRelation implements IncrementalRelation {
 
     private final Map<String, String> optParams;
+    private final Set<String> timestampPartitionKeys;
     private final HoodieTableMetaClient metaClient;
     private final HollowCommitHandling hollowCommitHandling;
     private final boolean startInstantArchived;
@@ -74,8 +76,9 @@ final class COWIncrementalRelation implements 
IncrementalRelation {
 
     COWIncrementalRelation(HoodieTableMetaClient metaClient, Configuration 
configuration,
             String startTs, String endTs, HollowCommitHandling 
hollowCommitHandling,
-            Map<String, String> optParams) throws IOException {
+            Map<String, String> optParams, Set<String> timestampPartitionKeys) 
throws IOException {
         this.optParams = optParams;
+        this.timestampPartitionKeys = timestampPartitionKeys;
         this.metaClient = metaClient;
         this.hollowCommitHandling = hollowCommitHandling;
         this.startTs = startTs;
@@ -209,7 +212,7 @@ final class COWIncrementalRelation implements 
IncrementalRelation {
                     
.fileFormat(HudiScanPlanProvider.detectFileFormat(baseFile))
                     .partitionValues(
                             HudiScanPlanProvider.parsePartitionValues(
-                                    stat.getPartitionPath(), partitionNames, 
urlEncoded))
+                                    stat.getPartitionPath(), partitionNames, 
urlEncoded, timestampPartitionKeys))
                     .build());
         };
 
diff --git 
a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java
 
b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java
index ec0d4bb8bd1..60f0a969b7d 100644
--- 
a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java
+++ 
b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiConnectorMetadata.java
@@ -224,6 +224,10 @@ public class HudiConnectorMetadata implements 
ConnectorMetadata {
                 .inputFormat(tableInfo.getInputFormat())
                 .serdeLib(tableInfo.getSerializationLib())
                 .partitionKeyNames(partKeyNames)
+                .timestampPartitionKeys(tableInfo.getPartitionKeys().stream()
+                        .filter(column -> 
"DATETIMEV2".equals(column.getType().getTypeName())
+                                || 
"TIMESTAMPTZ".equals(column.getType().getTypeName()))
+                        
.map(ConnectorColumn::getName).collect(Collectors.toSet()))
                 .tableParameters(tableInfo.getParameters())
                 .build());
     }
@@ -297,7 +301,7 @@ public class HudiConnectorMetadata implements 
ConnectorMetadata {
             }
             allPartPaths = hmsPartitionPaths(hudiHandle, allPartPaths);
             matchedPartPaths = prunePartitionPaths(allPartPaths, partKeyNames, 
partitionPredicates,
-                    urlEncodePartitioning(hudiHandle, allPartPaths));
+                    urlEncodePartitioning(hudiHandle, allPartPaths), 
hudiHandle.getTimestampPartitionKeys());
         } else {
             // non-hive-sync (Hudi default), or any pinned read: list the 
RELATIVE storage paths from Hudi
             // metadata -- the SAME source the unpruned scan 
(resolvePartitions -> listAllPartitionPaths) uses --
@@ -315,7 +319,7 @@ public class HudiConnectorMetadata implements 
ConnectorMetadata {
                 return Optional.empty();
             }
             matchedPartPaths = prunePartitionPaths(allPartPaths, partKeyNames, 
partitionPredicates,
-                    listing.urlEncoded);
+                    listing.urlEncoded, 
hudiHandle.getTimestampPartitionKeys());
         }
         if (matchedPartPaths.size() == allPartPaths.size()) {
             // No pruning effect
@@ -807,7 +811,8 @@ public class HudiConnectorMetadata implements 
ConnectorMetadata {
                 // non-numeric pin, the day it became reachable.
                 List<String> paths = hmsPartitionPaths(handle, hmsNames);
                 PartitionState state = latestPartitionState(handle);
-                return buildPartitionInfos(paths, partKeyNames, 
state.lastModifiedMillis, state.urlEncoded);
+                return buildPartitionInfos(paths, partKeyNames, 
state.lastModifiedMillis, state.urlEncoded,
+                        handle.getTimestampPartitionKeys());
             }
             LOG.warn("hive-sync hudi table {}.{} has no HMS partitions; "
                     + "falling back to hudi metadata partition listing",
@@ -837,7 +842,8 @@ public class HudiConnectorMetadata implements 
ConnectorMetadata {
                     HudiScanPlanProvider.urlEncodePartitioning(metaClient));
         });
         List<ConnectorPartitionInfo> partitions =
-                buildPartitionInfos(listing.rawPaths, partKeyNames, 
listing.lastModifiedMillis, listing.urlEncoded);
+                buildPartitionInfos(listing.rawPaths, partKeyNames, 
listing.lastModifiedMillis, listing.urlEncoded,
+                        handle.getTimestampPartitionKeys());
         return listing.pinned == null
                 ? partitions
                 : restrictToPin(handle, partKeyNames, partitions, 
listing.pinned, queryInstant, listing.urlEncoded);
@@ -921,7 +927,8 @@ public class HudiConnectorMetadata implements 
ConnectorMetadata {
             List<ConnectorPartitionInfo> partitions, PinnedListing pinned, 
String queryInstant, boolean urlEncoded) {
         Set<Map<String, String>> pinnedValues = new HashSet<>();
         for (String rawPath : pinned.rawPaths) {
-            
pinnedValues.add(HudiScanPlanProvider.parsePartitionValues(rawPath, 
partKeyNames, urlEncoded));
+            
pinnedValues.add(HudiScanPlanProvider.parsePartitionValues(rawPath, 
partKeyNames, urlEncoded,
+                    handle.getTimestampPartitionKeys()));
         }
         List<ConnectorPartitionInfo> atPin = new 
ArrayList<>(pinnedValues.size());
         for (ConnectorPartitionInfo partition : partitions) {
@@ -1002,11 +1009,17 @@ public class HudiConnectorMetadata implements 
ConnectorMetadata {
      */
     static List<ConnectorPartitionInfo> buildPartitionInfos(
             List<String> rawPaths, List<String> partKeyNames, long 
lastModifiedMillis, boolean urlEncoded) {
+        return buildPartitionInfos(rawPaths, partKeyNames, lastModifiedMillis, 
urlEncoded, Collections.emptySet());
+    }
+
+    static List<ConnectorPartitionInfo> buildPartitionInfos(List<String> 
rawPaths, List<String> partKeyNames,
+            long lastModifiedMillis, boolean urlEncoded, Set<String> 
timestampPartitionKeys) {
         List<ConnectorPartitionInfo> result = new ArrayList<>(rawPaths.size());
         for (String rawPath : rawPaths) {
             // Parse the unescaped values ONCE; render the hive-style name 
from the SAME values so the name and
             // the values map agree by construction (the name re-parses back 
to these values in fe-core).
-            Map<String, String> values = 
HudiScanPlanProvider.parsePartitionValues(rawPath, partKeyNames, urlEncoded);
+            Map<String, String> values = 
HudiScanPlanProvider.parsePartitionValues(rawPath, partKeyNames, urlEncoded,
+                    timestampPartitionKeys);
             String name = 
HudiScanPlanProvider.renderHiveStylePartitionName(partKeyNames, values);
             // Ordered values in partKeyNames (render) order; render/parse are 
exact inverses, so this equals
             // fe-core's legacy parse of `name`. Supplied so fe-core skips the 
parse.
@@ -1477,10 +1490,15 @@ public class HudiConnectorMetadata implements 
ConnectorMetadata {
      */
     static List<String> prunePartitionPaths(List<String> allPartPaths,
             List<String> partKeyNames, Map<String, List<String>> predicates, 
boolean urlEncoded) {
+        return prunePartitionPaths(allPartPaths, partKeyNames, predicates, 
urlEncoded, Collections.emptySet());
+    }
+
+    static List<String> prunePartitionPaths(List<String> allPartPaths, 
List<String> partKeyNames,
+            Map<String, List<String>> predicates, boolean urlEncoded, 
Set<String> timestampPartitionKeys) {
         List<String> matched = new ArrayList<>();
         for (String partPath : allPartPaths) {
             Map<String, String> partValues = 
HudiScanPlanProvider.parsePartitionValues(
-                    partPath, partKeyNames, urlEncoded);
+                    partPath, partKeyNames, urlEncoded, 
timestampPartitionKeys);
             if (matchesPredicates(partValues, predicates)) {
                 matched.add(partPath);
             }
diff --git 
a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java
 
b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java
index 091d61f994a..be4e81dd629 100644
--- 
a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java
+++ 
b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiScanPlanProvider.java
@@ -73,6 +73,7 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
@@ -398,7 +399,8 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
             IncrementalRelation relation = 
buildIncrementalRelation(metaClient, conf, hudiHandle, isCow);
             Optional<List<ConnectorScanRange>> incrementalRanges = 
incrementalRanges(relation, isCow, forceJni,
                     basePath, inputFormat, serdeLib, columnNames, columnTypes, 
partitionFieldNames(metaClient),
-                    this::normalizeNativeUri, 
urlEncodePartitioning(metaClient));
+                    this::normalizeNativeUri, 
urlEncodePartitioning(metaClient),
+                    hudiHandle.getTimestampPartitionKeys());
             if (incrementalRanges.isPresent()) {
                 LOG.info("Hudi incremental scan planning: {}.{} window=({}, 
{}] splits={}",
                         hudiHandle.getDbName(), hudiHandle.getTableName(),
@@ -423,7 +425,8 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
             boolean urlEncoded = urlEncodePartitioning(metaClient);
             for (String partitionPath : partitionPaths) {
                 Map<String, String> partValues = parsePartitionValues(
-                        partitionPath, hudiHandle.getPartitionKeyNames(), 
urlEncoded);
+                        partitionPath, hudiHandle.getPartitionKeyNames(), 
urlEncoded,
+                        hudiHandle.getTimestampPartitionKeys());
 
                 if (useNativeCowPath) {
                     collectCowSplits(fsView, partitionPath, queryInstant,
@@ -768,7 +771,7 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
         try {
             return isCow
                     ? new COWIncrementalRelation(metaClient, conf, 
handle.getBeginInstant(),
-                            handle.getEndInstant(), policy, optParams)
+                            handle.getEndInstant(), policy, optParams, 
handle.getTimestampPartitionKeys())
                     : new MORIncrementalRelation(metaClient, conf, 
handle.getBeginInstant(),
                             handle.getEndInstant(), policy, optParams);
         } catch (IOException e) {
@@ -803,6 +806,14 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
             boolean forceJni, String basePath, String inputFormat, String 
serdeLib,
             List<String> columnNames, List<String> columnTypes, List<String> 
partitionFieldNames,
             UnaryOperator<String> nativePathNormalizer, boolean urlEncoded) {
+        return incrementalRanges(relation, isCow, forceJni, basePath, 
inputFormat, serdeLib, columnNames,
+                columnTypes, partitionFieldNames, nativePathNormalizer, 
urlEncoded, Collections.emptySet());
+    }
+
+    static Optional<List<ConnectorScanRange>> 
incrementalRanges(IncrementalRelation relation, boolean isCow,
+            boolean forceJni, String basePath, String inputFormat, String 
serdeLib,
+            List<String> columnNames, List<String> columnTypes, List<String> 
partitionFieldNames,
+            UnaryOperator<String> nativePathNormalizer, boolean urlEncoded, 
Set<String> timestampPartitionKeys) {
         if (relation.fallbackFullTableScan()) {
             return Optional.empty();
         }
@@ -816,7 +827,7 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
         String endTs = relation.getEndTs();
         for (FileSlice fileSlice : relation.collectFileSlices()) {
             Map<String, String> partValues = parsePartitionValues(
-                    fileSlice.getPartitionPath(), partitionFieldNames, 
urlEncoded);
+                    fileSlice.getPartitionPath(), partitionFieldNames, 
urlEncoded, timestampPartitionKeys);
             // @incr lists the LATEST schema (no per-file schema_id dict on 
the incremental path) -> null resolver.
             ranges.add(buildMorRange(fileSlice, partValues, endTs, forceJni,
                     basePath, inputFormat, serdeLib, columnNames, columnTypes, 
null, nativePathNormalizer));
@@ -1085,8 +1096,8 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
     /**
      * Parse a Hudi partition's relative path into a column&rarr;value map. 
Handles BOTH hive-style
      * ("year=2024/month=01") and Hudi's
-     * DEFAULT non-hive-style POSITIONAL ("2024/01") layouts, decoding values 
only when the table enables
-     * URL-encoded partition paths:
+     * DEFAULT non-hive-style POSITIONAL ("2024/01") layouts, decoding values 
when the table enables
+     * URL-encoded partition paths or the column is a timestamp:
      * <ul>
      *   <li>A fragment carrying the "col=" prefix contributes the suffix; a 
fragment WITHOUT it is mapped
      *       POSITIONALLY to the i-th partition column. The old split-on-'=' 
logic silently DROPPED a
@@ -1103,10 +1114,16 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
      * <p>Static + package-private for direct unit testing (no live 
HoodieTableMetaClient needed).
      *
      * <p>The encoding flag comes from hoodie.properties, not the presence of 
percent characters. With
-     * encoding disabled (Hudi's default), "%3A" is a literal value distinct 
from ":".
+     * encoding disabled (Hudi's default), "%3A" in a STRING is a literal 
value distinct from ":".
+     * Spark SQL timestamp paths are always escaped, so callers supply their 
schema-derived column names.
      */
     static Map<String, String> parsePartitionValues(
             String partitionPath, List<String> partKeyNames, boolean 
urlEncoded) {
+        return parsePartitionValues(partitionPath, partKeyNames, urlEncoded, 
Collections.emptySet());
+    }
+
+    static Map<String, String> parsePartitionValues(String partitionPath, 
List<String> partKeyNames,
+            boolean urlEncoded, Set<String> timestampPartitionKeys) {
         if (partKeyNames == null || partKeyNames.isEmpty()) {
             // Non-partitioned table (legacy returns an empty value list). The 
unpartitioned scan path always
             // reaches here with an empty key list, so an empty partitionPath 
needs no separate guard.
@@ -1119,7 +1136,8 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
                 String prefix = partKeyNames.get(0) + "=";
                 String value = partitionPath.startsWith(prefix)
                         ? partitionPath.substring(prefix.length()) : 
partitionPath;
-                values.put(partKeyNames.get(0), urlEncoded ? 
unescapePathName(value) : value);
+                values.put(partKeyNames.get(0), decodePartitionValue(value, 
partKeyNames.get(0),
+                        urlEncoded, timestampPartitionKeys));
                 return values;
             }
             throw new DorisConnectorException(
@@ -1129,11 +1147,19 @@ public class HudiScanPlanProvider implements 
ConnectorScanPlanProvider {
             String prefix = partKeyNames.get(i) + "=";
             String raw = fragments[i].startsWith(prefix)
                     ? fragments[i].substring(prefix.length()) : fragments[i];
-            values.put(partKeyNames.get(i), urlEncoded ? unescapePathName(raw) 
: raw);
+            values.put(partKeyNames.get(i), decodePartitionValue(raw, 
partKeyNames.get(i),
+                    urlEncoded, timestampPartitionKeys));
         }
         return values;
     }
 
+    private static String decodePartitionValue(String value, String name, 
boolean urlEncoded,
+            Set<String> timestampPartitionKeys) {
+        // Spark SQL's SqlKeyGenerator always escapes formatted TIMESTAMP 
values, independently of the
+        // table URL-encoding flag. Decode these once, while preserving 
literal "%3A" in unencoded STRINGs.
+        return urlEncoded || timestampPartitionKeys.contains(name) ? 
unescapePathName(value) : value;
+    }
+
     /**
      * How hudi spells a NULL partition value in a directory name. 
Byte-frozen: it is what this connector has
      * always SENT in columns_from_path, and it is also accepted as an INPUT 
spelling.
diff --git 
a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTableHandle.java
 
b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTableHandle.java
index c8e7621e6f5..41c7e84478c 100644
--- 
a/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTableHandle.java
+++ 
b/fe/fe-connector/fe-connector-hudi/src/main/java/org/apache/doris/connector/hudi/HudiTableHandle.java
@@ -20,8 +20,10 @@ package org.apache.doris.connector.hudi;
 import org.apache.doris.connector.spi.handle.ConnectorTableHandle;
 
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * Opaque table handle for a Hudi table, carrying database/table coordinates,
@@ -44,6 +46,7 @@ public class HudiTableHandle implements ConnectorTableHandle {
     private final String inputFormat;
     private final String serdeLib;
     private final List<String> partitionKeyNames;
+    private final Set<String> timestampPartitionKeys;
     private final Map<String, String> tableParameters;
 
     // Set after applyFilter for partition pruning
@@ -80,6 +83,7 @@ public class HudiTableHandle implements ConnectorTableHandle {
         this.partitionKeyNames = builder.partitionKeyNames != null
                 ? Collections.unmodifiableList(builder.partitionKeyNames)
                 : Collections.emptyList();
+        this.timestampPartitionKeys = Collections.unmodifiableSet(new 
HashSet<>(builder.timestampPartitionKeys));
         this.tableParameters = builder.tableParameters != null
                 ? Collections.unmodifiableMap(builder.tableParameters)
                 : Collections.emptyMap();
@@ -125,6 +129,10 @@ public class HudiTableHandle implements 
ConnectorTableHandle {
         return partitionKeyNames;
     }
 
+    public Set<String> getTimestampPartitionKeys() {
+        return timestampPartitionKeys;
+    }
+
     public Map<String, String> getTableParameters() {
         return tableParameters;
     }
@@ -165,6 +173,7 @@ public class HudiTableHandle implements 
ConnectorTableHandle {
         b.inputFormat = this.inputFormat;
         b.serdeLib = this.serdeLib;
         b.partitionKeyNames = this.partitionKeyNames;
+        b.timestampPartitionKeys = this.timestampPartitionKeys;
         b.tableParameters = this.tableParameters;
         b.prunedPartitionPaths = this.prunedPartitionPaths;
         b.queryInstant = this.queryInstant;
@@ -191,6 +200,7 @@ public class HudiTableHandle implements 
ConnectorTableHandle {
         private String inputFormat;
         private String serdeLib;
         private List<String> partitionKeyNames;
+        private Set<String> timestampPartitionKeys = Collections.emptySet();
         private Map<String, String> tableParameters;
         private List<String> prunedPartitionPaths;
         private String queryInstant;
@@ -220,6 +230,11 @@ public class HudiTableHandle implements 
ConnectorTableHandle {
             return this;
         }
 
+        public Builder timestampPartitionKeys(Set<String> val) {
+            this.timestampPartitionKeys = val;
+            return this;
+        }
+
         public Builder tableParameters(Map<String, String> val) {
             this.tableParameters = val;
             return this;
diff --git 
a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java
 
b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java
index 67260d13a8f..8bef7f3c473 100644
--- 
a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java
+++ 
b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiConnectorPartitionListingTest.java
@@ -24,6 +24,7 @@ import org.apache.doris.connector.hms.HmsClientException;
 import org.apache.doris.connector.hms.HmsDatabaseInfo;
 import org.apache.doris.connector.hms.HmsPartitionInfo;
 import org.apache.doris.connector.hms.HmsTableInfo;
+import org.apache.doris.connector.spi.ConnectorColumn;
 import org.apache.doris.connector.spi.ConnectorPartitionInfo;
 import org.apache.doris.connector.spi.ConnectorType;
 import org.apache.doris.connector.spi.DorisConnectorException;
@@ -37,6 +38,7 @@ import 
org.apache.doris.connector.spi.pushdown.ConnectorLiteral;
 import org.apache.hadoop.conf.Configuration;
 import org.apache.hudi.common.model.HoodieTableType;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.util.PartitionPathEncodeUtils;
 import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -77,6 +79,100 @@ public class HudiConnectorPartitionListingTest {
 
     // ── item 1: instant string → long (pure) 
──────────────────────────────────────────────────────────
 
+    @Test
+    public void 
sparkSqlTimestampPartitionsDecodeWithoutChangingStringPartitions() {
+        String timestamp = "2024-01-01 10:00:00";
+        // SqlKeyGenerator escapes TIMESTAMP values even when URL encoding is 
disabled for STRING values.
+        String physical = "event_time=" + 
PartitionPathEncodeUtils.escapePathName(timestamp) + "/label=%3A";
+        RecordingHmsClient hms = new RecordingHmsClient(null);
+        hms.tableInfo = timestampTable("s3://b/t");
+        HudiConnectorMetadata md = metadata(false, hms,
+                stub(HudiConnectorMetadata.unpinnedListing(99, 
Collections.singletonList(physical), false)));
+        HudiTableHandle handle = (HudiTableHandle) md.getTableHandle(null, 
"db", "t").get();
+        ConnectorPartitionInfo partition = md.listPartitions(null, handle, 
Optional.empty()).get(0);
+        Assertions.assertEquals(timestamp, 
partition.getPartitionValues().get("event_time"));
+        Assertions.assertEquals("%3A", 
partition.getPartitionValues().get("label"));
+        Assertions.assertEquals(Arrays.asList(timestamp, "%3A"), 
partition.getOrderedPartitionValues());
+        Assertions.assertEquals(partition.getPartitionValues(),
+                
HudiConnectorMetadata.parsePartitionName(partition.getPartitionName(), 
handle.getPartitionKeyNames()));
+    }
+
+    @Test
+    public void 
sparkSqlTimestampHmsListingAndPruningRespectPersistedUrlFlags(@TempDir Path 
warehouse)
+            throws Exception {
+        String first = "2024-01-01 10:00:00";
+        String second = "2024-01-02 11:00:00";
+        for (Boolean encoded : Arrays.asList(null, false, true)) {
+            String basePath = 
warehouse.resolve(String.valueOf(encoded)).toUri().toString();
+            HoodieTableMetaClient.TableBuilder builder = 
HoodieTableMetaClient.newTableBuilder()
+                    
.setTableType(HoodieTableType.COPY_ON_WRITE).setTableName("t")
+                    .setPartitionFields("event_time,label");
+            if (encoded != null) {
+                builder.setUrlEncodePartitioning(encoded);
+            }
+            builder.initTable(new HadoopStorageConfiguration(new 
Configuration()), basePath);
+            String label = Boolean.TRUE.equals(encoded) ? "%253A" : "%3A";
+            List<String> paths = Arrays.asList(
+                    "event_time=" + 
PartitionPathEncodeUtils.escapePathName(first) + "/label=" + label,
+                    "event_time=" + 
PartitionPathEncodeUtils.escapePathName(second) + "/label=" + label);
+            List<String> names = paths.stream().map(path -> path.replace("%", 
"%25")).collect(Collectors.toList());
+            RecordingHmsClient hms = new RecordingHmsClient(names);
+            hms.tableInfo = timestampTable(basePath);
+            hms.locations = Map.of(names.get(0), basePath + "/" + paths.get(0),
+                    names.get(1), basePath + "/" + paths.get(1));
+            HudiConnectorMetadata md = new HudiConnectorMetadata(hms,
+                    
HudiTestProperties.with(HudiCatalogProperties.USE_HIVE_SYNC_PARTITION, "true"),
+                    HudiMetaClientExecutor.inline());
+            HudiTableHandle handle = (HudiTableHandle) md.getTableHandle(null, 
"db", "t").get();
+            List<ConnectorPartitionInfo> partitions = md.listPartitions(null, 
handle, Optional.empty());
+            Assertions.assertEquals(Arrays.asList(first, second), 
partitions.stream()
+                    .map(partition -> 
partition.getPartitionValues().get("event_time")).collect(Collectors.toList()));
+            Assertions.assertEquals(Arrays.asList("%3A", "%3A"), 
partitions.stream()
+                    .map(partition -> 
partition.getPartitionValues().get("label")).collect(Collectors.toList()));
+            ConnectorComparison predicate = new 
ConnectorComparison(ConnectorComparison.Operator.EQ,
+                    new ConnectorColumnRef("event_time", 
ConnectorType.of("DATETIMEV2", 6, -1)),
+                    new ConnectorLiteral(ConnectorType.of("DATETIMEV2", 6, -1),
+                            LocalDateTime.of(2024, 1, 1, 10, 0)));
+            HudiTableHandle filtered = (HudiTableHandle) md.applyFilter(null, 
handle,
+                    new 
ConnectorFilterConstraint(predicate)).get().getHandle();
+            Assertions.assertEquals(Collections.singletonList(paths.get(0)), 
filtered.getPrunedPartitionPaths());
+            Assertions.assertEquals(Collections.singleton("event_time"), 
filtered.getTimestampPartitionKeys());
+        }
+    }
+
+    @Test
+    public void 
sparkSqlTimestampMetadataPruningAndHistoricalListingKeepPhysicalPaths() {
+        List<String> paths = Arrays.asList("event_time=2024-01-01 
10%3A00%3A00/label=%3A",
+                "event_time=2024-01-02 11%3A00%3A00/label=%3A");
+        RecordingHmsClient hms = new RecordingHmsClient(null);
+        hms.tableInfo = timestampTable("s3://b/t");
+        HudiConnectorMetadata current = metadata(false, hms,
+                stub(HudiConnectorMetadata.unpinnedListing(99, paths, false)));
+        HudiTableHandle handle = (HudiTableHandle) 
current.getTableHandle(null, "db", "t").get();
+        ConnectorComparison predicate = new 
ConnectorComparison(ConnectorComparison.Operator.EQ,
+                new ConnectorColumnRef("event_time", 
ConnectorType.of("DATETIMEV2", 6, -1)),
+                new ConnectorLiteral(ConnectorType.of("DATETIMEV2", 6, -1), 
LocalDateTime.of(2024, 1, 1, 10, 0)));
+        HudiTableHandle filtered = (HudiTableHandle) current.applyFilter(null, 
handle,
+                new ConnectorFilterConstraint(predicate)).get().getHandle();
+        Assertions.assertEquals(Collections.singletonList(paths.get(0)), 
filtered.getPrunedPartitionPaths());
+        HudiConnectorMetadata historical = metadata(false, hms,
+                stub(HudiConnectorMetadata.pinnedListing(99, paths, 7,
+                        Collections.singletonList(paths.get(0)), false)));
+        List<ConnectorPartitionInfo> partitions = 
historical.listPartitions(null,
+                handle.toBuilder().queryInstant("20240101120000000").build(), 
Optional.empty());
+        Assertions.assertEquals(1, partitions.size());
+        Assertions.assertEquals(Arrays.asList("2024-01-01 10:00:00", "%3A"),
+                partitions.get(0).getOrderedPartitionValues());
+    }
+
+    private static HmsTableInfo timestampTable(String basePath) {
+        return 
HmsTableInfo.builder().dbName("db").tableName("t").location(basePath)
+                .partitionKeys(Arrays.asList(
+                        new ConnectorColumn("event_time", 
ConnectorType.of("DATETIMEV2", 6, -1), null, true, null),
+                        new ConnectorColumn("label", 
ConnectorType.of("STRING"), null, true, null)))
+                .build();
+    }
+
     @Test
     public void 
metadataListingAndTimeTravelKeepLiteralPercentPartitionsDistinct() {
         for (boolean encoded : Arrays.asList(false, true)) {
@@ -695,6 +791,7 @@ public class HudiConnectorPartitionListingTest {
     /** Records partition lookups and supplies locations; unrelated HMS 
operations fail loudly. */
     private static final class RecordingHmsClient implements HmsClient {
         private final List<String> partitionNames;
+        private HmsTableInfo tableInfo;
         private Map<String, String> locations = Collections.emptyMap();
         private List<String> deletedNames = Collections.emptyList();
         private int getPartitionsCalls;
@@ -730,12 +827,15 @@ public class HudiConnectorPartitionListingTest {
 
         @Override
         public boolean tableExists(String dbName, String tableName) {
-            throw new UnsupportedOperationException();
+            return tableInfo != null;
         }
 
         @Override
         public HmsTableInfo getTable(String dbName, String tableName) {
-            throw new UnsupportedOperationException();
+            if (tableInfo == null) {
+                throw new UnsupportedOperationException("No table metadata 
configured");
+            }
+            return tableInfo;
         }
 
         @Override
diff --git 
a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalPlanScanTest.java
 
b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalPlanScanTest.java
index a9bb6b92c76..7d5fe83b534 100644
--- 
a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalPlanScanTest.java
+++ 
b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiIncrementalPlanScanTest.java
@@ -18,12 +18,27 @@
 package org.apache.doris.connector.hudi;
 
 import org.apache.doris.connector.spi.scan.ConnectorScanRange;
+import org.apache.doris.thrift.TFileRangeDesc;
+import org.apache.doris.thrift.TTableFormatFileDesc;
 
+import org.apache.hadoop.conf.Configuration;
 import org.apache.hudi.common.model.FileSlice;
 import org.apache.hudi.common.model.HoodieBaseFile;
+import org.apache.hudi.common.model.HoodieCommitMetadata;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.HoodieWriteStat;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
+import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.timeline.HoodieTimeline;
+import 
org.apache.hudi.common.table.timeline.TimelineUtils.HollowCommitHandling;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
 
+import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -33,18 +48,13 @@ import java.util.Optional;
 import java.util.function.UnaryOperator;
 
 /**
- * Tests the OFFLINE-verifiable core of the INC-3 incremental {@code planScan} 
wiring: the pure static helpers
- * {@link HudiScanPlanProvider#incrementalRanges} (COW/MOR routing + fallback 
degrade) and
- * {@link HudiScanPlanProvider#buildMorRange} (file-slice &rarr; JNI range 
mapping). Both are exercised without a
- * live {@code HoodieTableMetaClient} — {@code incrementalRanges} via a FAKE 
{@link IncrementalRelation}, and
- * {@code buildMorRange} via a hand-built {@link FileSlice}.
+ * Tests incremental scan-range mapping through both a real local Hudi commit 
and recording fake relations.
+ * Covers {@link HudiScanPlanProvider#incrementalRanges} (COW/MOR routing and 
fallback) and
+ * {@link HudiScanPlanProvider#buildMorRange} (file-slice to native/JNI range 
mapping).
  *
- * <p><b>Coverage scope (Rule 12 — no over-claim).</b> These cover the routing 
+ mapping DECISIONS only. Building
- * the real relation (which does eager timeline/metadata/filesystem I/O) and 
the actual file SELECTION
- * (commit-range, write-stat mapping, {@code fs.exists} full-table-scan 
probes, the completion-time END axis) are
- * e2e-only (design §5) — same boundary as {@link 
HudiIncrementalRelationTest}. Row-level filtering of the read to
- * the {@code (begin, end]} window is a LATER step (an FE-side synthetic 
{@code _hoodie_commit_time} predicate),
- * NOT this file-selection step.
+ * <p>The COW timestamp regression persists a local SDK commit and exercises 
the real relation's write-stat
+ * mapping. Other selection scenarios use a fake relation; these tests do not 
decode Parquet data or exercise
+ * a remote Hudi deployment. Incremental row-level filtering is outside the 
scan-range mapping tested here.
  */
 public class HudiIncrementalPlanScanTest {
 
@@ -56,6 +66,65 @@ public class HudiIncrementalPlanScanTest {
 
     // ── fallback → degrade (Optional.empty), collect* NOT called 
─────────────────────────────────────────────
 
+    @Test
+    public void 
cowIncrementalTimestampDecodingPreservesCommittedFilePath(@TempDir Path 
warehouse) throws Exception {
+        Configuration configuration = new Configuration();
+        HoodieTableMetaClient metaClient = 
HoodieTableMetaClient.newTableBuilder()
+                .setTableType(HoodieTableType.COPY_ON_WRITE).setTableName("t")
+                
.setPartitionFields("event_time,label").setUrlEncodePartitioning(false)
+                .initTable(new HadoopStorageConfiguration(configuration), 
warehouse.toUri().toString());
+        String partition = "event_time=2024-01-01 10%3A00%3A00/label=%3A";
+        String instantTime = "20240101120000000";
+        String relativeFile = partition + "/fileid_0_" + instantTime + 
".parquet";
+        HoodieWriteStat stat = new HoodieWriteStat();
+        stat.setFileId("fileid");
+        stat.setPartitionPath(partition);
+        stat.setPath(relativeFile);
+        stat.setFileSizeInBytes(128);
+        HoodieCommitMetadata metadata = new HoodieCommitMetadata();
+        metadata.addWriteStat(partition, stat);
+        HoodieActiveTimeline timeline = metaClient.getActiveTimeline();
+        HoodieInstant requested = 
metaClient.createNewInstant(HoodieInstant.State.REQUESTED,
+                HoodieTimeline.COMMIT_ACTION, instantTime);
+        timeline.createNewInstant(requested);
+        timeline.transitionRequestedToInflight(requested, Option.empty());
+        
timeline.saveAsComplete(metaClient.createNewInstant(HoodieInstant.State.INFLIGHT,
+                HoodieTimeline.COMMIT_ACTION, instantTime), 
Option.of(metadata));
+        metaClient.reloadActiveTimeline();
+        COWIncrementalRelation relation = new 
COWIncrementalRelation(metaClient, configuration,
+                "20240101000000000", instantTime, HollowCommitHandling.FAIL,
+                Collections.emptyMap(), Collections.singleton("event_time"));
+        List<HudiScanRange> ranges = 
relation.collectSplits(UnaryOperator.identity());
+        Assertions.assertEquals(1, ranges.size());
+        HudiScanRange range = ranges.get(0);
+        Assertions.assertTrue(range.getPath().get().endsWith(relativeFile));
+        TFileRangeDesc descriptor = new TFileRangeDesc();
+        range.populateRangeParams(new TTableFormatFileDesc(), descriptor);
+        Assertions.assertEquals(Arrays.asList("event_time", "label"), 
descriptor.getColumnsFromPathKeys());
+        Assertions.assertEquals(Arrays.asList("2024-01-01 10:00:00", "%3A"), 
descriptor.getColumnsFromPath());
+    }
+
+    @Test
+    public void morIncrementalTimestampDecodingReachesNativeAndJniScanRanges() 
{
+        String path = "event_time=2024-01-01 10%3A00%3A00/label=%3A";
+        String file = BASE_PATH + "/" + path + 
"/fileid_0_20240101000000.parquet";
+        for (boolean forceJni : Arrays.asList(false, true)) {
+            FakeRelation mor = new FakeRelation();
+            mor.fileSlices = Collections.singletonList(baseOnlySlice(path, 
file));
+            HudiScanRange range = (HudiScanRange) 
HudiScanPlanProvider.incrementalRanges(
+                    mor, false, forceJni, BASE_PATH, INPUT_FORMAT, SERDE,
+                    Arrays.asList("event_time", "label"), 
Arrays.asList("timestamp", "string"),
+                    Arrays.asList("event_time", "label"), 
UnaryOperator.identity(), false,
+                    Collections.singleton("event_time")).get().get(0);
+            TFileRangeDesc descriptor = new TFileRangeDesc();
+            range.populateRangeParams(new TTableFormatFileDesc(), descriptor);
+            Assertions.assertEquals(Arrays.asList("event_time", "label"), 
descriptor.getColumnsFromPathKeys());
+            Assertions.assertEquals(Arrays.asList("2024-01-01 10:00:00", 
"%3A"), descriptor.getColumnsFromPath());
+            Assertions.assertEquals(Arrays.asList(false, false), 
descriptor.getColumnsFromPathIsNull());
+            Assertions.assertEquals(file, range.getPath().get());
+        }
+    }
+
     @Test
     public void morIncrementalRespectsPhysicalPartitionEncoding() {
         for (boolean encoded : Arrays.asList(false, true)) {
diff --git 
a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionValuesTest.java
 
b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionValuesTest.java
index ce946dd6ab9..5ff6834c5f6 100644
--- 
a/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionValuesTest.java
+++ 
b/fe/fe-connector/fe-connector-hudi/src/test/java/org/apache/doris/connector/hudi/HudiPartitionValuesTest.java
@@ -19,6 +19,7 @@ package org.apache.doris.connector.hudi;
 
 import org.apache.doris.connector.spi.DorisConnectorException;
 
+import org.apache.hudi.common.util.PartitionPathEncodeUtils;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
@@ -33,6 +34,34 @@ import java.util.Map;
  */
 public class HudiPartitionValuesTest {
 
+    @Test
+    public void timestampDecodingIsPerColumnAndConsumesOnlyOneEscapingLayer() {
+        String timestamp = "2024-01-01 10:00:00";
+        String timestampPath = 
PartitionPathEncodeUtils.escapePathName(timestamp);
+        for (boolean urlEncoded : Arrays.asList(false, true)) {
+            for (boolean hiveStyle : Arrays.asList(false, true)) {
+                for (String label : Arrays.asList("%3A", "%253A", "a+b", 
"a%2Fb")) {
+                    String labelPath = urlEncoded ? 
PartitionPathEncodeUtils.escapePathName(label) : label;
+                    String path = (hiveStyle ? "event_time=" : "") + 
timestampPath + "/"
+                            + (hiveStyle ? "label=" : "") + labelPath;
+                    Map<String, String> values = 
HudiScanPlanProvider.parsePartitionValues(path,
+                            Arrays.asList("event_time", "label"), urlEncoded, 
Collections.singleton("event_time"));
+                    Assertions.assertEquals(timestamp, 
values.get("event_time"));
+                    Assertions.assertEquals(label, values.get("label"));
+                }
+            }
+        }
+        Assertions.assertEquals("2024-01-01 10%3A00%3A00", 
HudiScanPlanProvider.parsePartitionValues(
+                "event_time=2024-01-01 10%253A00%253A00", 
Collections.singletonList("event_time"),
+                false, Collections.singleton("event_time")).get("event_time"));
+        Assertions.assertEquals("2024-01-01 10:00:00", 
HudiScanPlanProvider.parsePartitionValues(
+                "event_time=" + timestamp, 
Collections.singletonList("event_time"),
+                false, Collections.singleton("event_time")).get("event_time"));
+        Assertions.assertEquals("__HIVE_DEFAULT_PARTITION__", 
HudiScanPlanProvider.parsePartitionValues(
+                "event_time=__HIVE_DEFAULT_PARTITION__", 
Collections.singletonList("event_time"),
+                false, Collections.singleton("event_time")).get("event_time"));
+    }
+
     @Test
     public void rawAndEncodedValuesHaveTheSameLogicalIdentity() {
         for (String value : Arrays.asList(":", "%3A", "%253A", "a/b", "a+b", 
"a%2Fb", "bad%2", "你好")) {
diff --git 
a/regression-test/suites/external_table_p2/hudi/test_hudi_partition_prune.groovy
 
b/regression-test/suites/external_table_p2/hudi/test_hudi_partition_prune.groovy
index a74f34aca05..961aaf69c33 100644
--- 
a/regression-test/suites/external_table_p2/hudi/test_hudi_partition_prune.groovy
+++ 
b/regression-test/suites/external_table_p2/hudi/test_hudi_partition_prune.groovy
@@ -385,6 +385,18 @@ suite("test_hudi_partition_prune", "p2,external") {
             contains "partition=1/2"
         }
 
+        // Spark SQL escapes timestamp partition values independently of the 
table URL-encoding flag.
+        // Keep the golden result above and also compare both readers under 
each partition-listing mode.
+        def originalForceJni = sql("SHOW VARIABLES LIKE 
'force_jni_scanner'")[0][1]
+        try {
+            sql """set force_jni_scanner = false"""
+            def nativeTimestampRows = sql(one_partition_timestamp)
+            sql """set force_jni_scanner = true"""
+            assertEquals(nativeTimestampRows, sql(one_partition_timestamp))
+        } finally {
+            sql """set force_jni_scanner = ${originalForceJni}"""
+        }
+
         // A partition that was dropped after the instant being read.
         //
         // Every other table here only ever gains partitions, so "the 
partitions now" and "the


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

Reply via email to