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

yihua 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 3ddf6285d52e fix(timeline-service): support response charset for 
Timeline Server interaction (#19753)
3ddf6285d52e is described below

commit 3ddf6285d52e69ce842ac9197509dee854ca1acf
Author: Lokesh Jain <[email protected]>
AuthorDate: Thu Aug 27 05:55:12 2026 +0530

    fix(timeline-service): support response charset for Timeline Server 
interaction (#19753)
    
    Co-authored-by: Y Ethan Guo <[email protected]>
---
 .../client/HoodieTableServiceManagerClient.java    |  7 ++-
 .../client/embedded/EmbeddedTimelineService.java   |  1 +
 .../table/timeline/TimelineServiceClient.java      |  7 ++-
 .../table/view/FileSystemViewStorageConfig.java    | 19 ++++++++
 .../org/apache/hudi/TestHoodieSparkSqlWriter.scala | 51 ++++++++++++++++++++++
 5 files changed, 83 insertions(+), 2 deletions(-)

diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTableServiceManagerClient.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTableServiceManagerClient.java
index ec7dd1b30b0e..96ed1f789dde 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTableServiceManagerClient.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/HoodieTableServiceManagerClient.java
@@ -21,6 +21,7 @@ package org.apache.hudi.client;
 import org.apache.hudi.common.config.HoodieTableServiceManagerConfig;
 import org.apache.hudi.common.table.HoodieTableMetaClient;
 import org.apache.hudi.common.table.timeline.HoodieInstant;
+import org.apache.hudi.common.table.view.FileSystemViewStorageConfig;
 import org.apache.hudi.common.util.ClusteringUtils;
 import org.apache.hudi.common.util.Option;
 import org.apache.hudi.common.util.RetryHelper;
@@ -34,6 +35,7 @@ import org.apache.http.client.utils.URIBuilder;
 
 import java.io.IOException;
 import java.net.URI;
+import java.nio.charset.Charset;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -78,6 +80,7 @@ public class HoodieTableServiceManagerClient {
   private final String basePath;
   private final String dbName;
   private final String tableName;
+  private final Charset responseCharset;
 
   public HoodieTableServiceManagerClient(HoodieTableMetaClient metaClient, 
HoodieTableServiceManagerConfig config) {
     this.basePath = metaClient.getBasePath().toString();
@@ -86,6 +89,8 @@ public class HoodieTableServiceManagerClient {
     this.uri = config.getTableServiceManagerURIs();
     this.config = config;
     this.metaClient = metaClient;
+    this.responseCharset = Charset.forName(
+        
config.getStringOrDefault(FileSystemViewStorageConfig.REMOTE_RESPONSE_CHARSET));
   }
 
   private String executeRequest(String requestPath, Map<String, String> 
queryParameters) throws IOException {
@@ -99,7 +104,7 @@ public class HoodieTableServiceManagerClient {
     int connectionRetryDelay = config.getConnectionRetryDelay();
 
     RetryHelper<String, IOException> retryHelper = new 
RetryHelper<>(connectionRetryDelay, requestRetryLimit, connectionRetryDelay, 
RETRY_EXCEPTIONS);
-    return retryHelper.tryWith(() -> 
Request.Get(url).connectTimeout(timeoutMs).socketTimeout(timeoutMs).execute().returnContent().asString()).start();
+    return retryHelper.tryWith(() -> 
Request.Get(url).connectTimeout(timeoutMs).socketTimeout(timeoutMs).execute().returnContent().asString(responseCharset)).start();
   }
 
   private Map<String, String> getParamsWithAdditionalParams(String[] 
paramNames, String[] paramVals) {
diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/embedded/EmbeddedTimelineService.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/embedded/EmbeddedTimelineService.java
index 495346a328d3..13a075d09f19 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/embedded/EmbeddedTimelineService.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/client/embedded/EmbeddedTimelineService.java
@@ -214,6 +214,7 @@ public class EmbeddedTimelineService {
         
.withRemoteTimelineInitialRetryIntervalMs(clientWriteConfig.getClientSpecifiedViewStorageConfig().getRemoteTimelineInitialRetryIntervalMs())
         
.withRemoteTimelineClientMaxRetryIntervalMs(clientWriteConfig.getClientSpecifiedViewStorageConfig().getRemoteTimelineClientMaxRetryIntervalMs())
         
.withRemoteTimelineClientRetryExceptions(clientWriteConfig.getClientSpecifiedViewStorageConfig().getRemoteTimelineClientRetryExceptions())
+        
.withRemoteResponseCharset(clientWriteConfig.getClientSpecifiedViewStorageConfig().getRemoteResponseCharset())
         .build();
   }
 
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineServiceClient.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineServiceClient.java
index f26b1fa97316..14c3a17b721e 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineServiceClient.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/timeline/TimelineServiceClient.java
@@ -23,7 +23,9 @@ import 
org.apache.hudi.common.table.view.FileSystemViewStorageConfig;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.http.client.utils.URIBuilder;
 
+import java.io.ByteArrayInputStream;
 import java.io.IOException;
+import java.nio.charset.Charset;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -39,12 +41,14 @@ public class TimelineServiceClient extends 
TimelineServiceClientBase {
   protected final String timelineServerHost;
   protected final int timelineServerPort;
   protected final int timeoutMs;
+  protected final String responseCharsetName;
 
   public TimelineServiceClient(FileSystemViewStorageConfig config) {
     super(config);
     this.timelineServerHost = config.getRemoteViewServerHost();
     this.timelineServerPort = config.getRemoteViewServerPort();
     this.timeoutMs = (int) 
TimeUnit.SECONDS.toMillis(config.getRemoteTimelineClientTimeoutSecs());
+    this.responseCharsetName = 
config.getStringOrDefault(FileSystemViewStorageConfig.REMOTE_RESPONSE_CHARSET);
   }
 
   @Override
@@ -59,7 +63,8 @@ public class TimelineServiceClient extends 
TimelineServiceClientBase {
     String url = builder.toString();
     log.debug("Sending request : ({})", url);
     org.apache.http.client.fluent.Response response = get(request.getMethod(), 
url, timeoutMs);
-    return new Response(response.returnContent().asStream());
+    return new Response(new ByteArrayInputStream(
+        
response.returnContent().asString(Charset.forName(responseCharsetName)).getBytes(Charset.forName(responseCharsetName))));
   }
 
   private org.apache.http.client.fluent.Response get(RequestMethod method, 
String url, int timeoutMs) throws IOException {
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewStorageConfig.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewStorageConfig.java
index 19741cd8ad3e..ae08886ed537 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewStorageConfig.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/table/view/FileSystemViewStorageConfig.java
@@ -27,6 +27,7 @@ import org.apache.hudi.common.util.ValidationUtils;
 import java.io.File;
 import java.io.FileReader;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
 import java.util.Properties;
 import java.util.stream.Collectors;
@@ -166,6 +167,15 @@ public class FileSystemViewStorageConfig extends 
HoodieConfig {
       .withDocumentation("The class name of the Exception that needs to be 
retried, separated by commas. "
           + "Default is empty which means retry all the IOException and 
RuntimeException from Remote Request.");
 
+  public static final ConfigProperty<String> REMOTE_RESPONSE_CHARSET = 
ConfigProperty
+      .key("hoodie.filesystem.view.remote.response.charset")
+      .defaultValue(StandardCharsets.ISO_8859_1.name())
+      .markAdvanced()
+      .sinceVersion("1.3.0")
+      .withDocumentation("Charset used for decoding HTTP responses from the 
timeline server. "
+          + "Set to 'UTF-8' if partition paths or file paths contain non-ASCII 
characters (e.g. Unicode). "
+          + "Default is ISO-8859-1 for backwards compatibility.");
+
   public static final ConfigProperty<String> REMOTE_BACKUP_VIEW_ENABLE = 
ConfigProperty
       .key("hoodie.filesystem.remote.backup.view.enable")
       .defaultValue("true") // Need to be disabled only for tests.
@@ -267,6 +277,10 @@ public class FileSystemViewStorageConfig extends 
HoodieConfig {
     return FileSystemViewStorageType.valueOf(getString(SECONDARY_VIEW_TYPE));
   }
 
+  public String getRemoteResponseCharset() {
+    return getStringOrDefault(REMOTE_RESPONSE_CHARSET);
+  }
+
   public boolean shouldEnableBackupForRemoteFileSystemView() {
     return getBoolean(REMOTE_BACKUP_VIEW_ENABLE);
   }
@@ -374,6 +388,11 @@ public class FileSystemViewStorageConfig extends 
HoodieConfig {
       return this;
     }
 
+    public Builder withRemoteResponseCharset(String charset) {
+      fileSystemViewStorageConfig.setValue(REMOTE_RESPONSE_CHARSET, charset);
+      return this;
+    }
+
     public Builder withEnableBackupForRemoteFileSystemView(boolean enable) {
       fileSystemViewStorageConfig.setValue(REMOTE_BACKUP_VIEW_ENABLE, 
Boolean.toString(enable));
       return this;
diff --git 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala
 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala
index 393030dd83b3..e4ae6bbdd086 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/TestHoodieSparkSqlWriter.scala
@@ -1420,6 +1420,57 @@ def testBulkInsertForDropPartitionColumn(): Unit = {
     val tableMetaClient = createMetaClient(spark, tempBasePath)
     new TableSchemaResolver(tableMetaClient).getTableSchema(false)
   }
+
+  /**
+   * Test that upsert works correctly when partition path contains Unicode 
characters.
+   * Reproduces a bug where UTF-8 bytes for characters like "ü" (U+00FC) get 
misinterpreted
+   * as Latin-1 during the String-to-Path round-trip, causing file-not-found 
errors on the
+   * second write.
+   */
+  @Test
+  def testUpsertWithUnicodePartitionPath(): Unit = {
+    val options = Map(
+      DataSourceWriteOptions.TABLE_TYPE.key -> 
HoodieTableType.COPY_ON_WRITE.name(),
+      DataSourceWriteOptions.PRECOMBINE_FIELD.key -> "ts",
+      DataSourceWriteOptions.RECORDKEY_FIELD.key -> "uuid",
+      DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "company",
+      DataSourceWriteOptions.KEYGENERATOR_CLASS_NAME.key -> 
"org.apache.hudi.keygen.SimpleKeyGenerator",
+      HoodieWriteConfig.TBL_NAME.key -> "hoodie_test",
+      "hoodie.insert.shuffle.parallelism" -> "1",
+      "hoodie.upsert.shuffle.parallelism" -> "1",
+      "hoodie.filesystem.view.remote.response.charset" -> "UTF-8"
+    )
+
+    // Unicode partition value containing German umlaut ü (U+00FC)
+    val unicodePartition = "M\u00fcnchen"
+
+    // First write - insert with Overwrite
+    val df = spark.createDataFrame(Seq(
+      ("id1", 100L, unicodePartition),
+      ("id2", 200L, unicodePartition)
+    )).toDF("uuid", "ts", "company")
+
+    df.write.format("hudi")
+      .options(options)
+      .mode(SaveMode.Overwrite)
+      .save(tempBasePath)
+
+    // Second write - upsert with Append (triggers reading existing Parquet 
data)
+    val dfUpdate = spark.createDataFrame(Seq(
+      ("id1", 300L, unicodePartition),
+      ("id2", 400L, unicodePartition)
+    )).toDF("uuid", "ts", "company")
+
+    dfUpdate.write.format("hudi")
+      .options(options)
+      .mode(SaveMode.Append)
+      .save(tempBasePath)
+
+    // Verify upserted data can be read back
+    val dfResult = spark.read.format("hudi").load(tempBasePath)
+    assert(dfResult.count() == 2)
+    assert(dfResult.where("ts >= 300").count() == 2)
+  }
 }
 
 object TestHoodieSparkSqlWriter {

Reply via email to