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 ecb7e8ff3893 feat(flink): support per-table Hadoop configuration
directories (#20433)
ecb7e8ff3893 is described below
commit ecb7e8ff389377f92dbc133e097508f9e62623cc
Author: cbg-wx <[email protected]>
AuthorDate: Thu Oct 29 18:01:28 2026 +0800
feat(flink): support per-table Hadoop configuration directories (#20433)
* Add Hadoop configuration directory hadoop_conf.dir support for Flink
cross-cluster Hudi read/write
* fix(flink): validate and propagate explicit Hadoop configuration
* refactor(flink): simplify Hadoop configuration directory validation
---------
Co-authored-by: danny0405 <[email protected]>
---
.../hudi/client/HoodieFlinkTableServiceClient.java | 5 +-
.../java/org/apache/hudi/util/FlinkClientUtil.java | 69 ++++--
.../client/TestHoodieFlinkTableServiceClient.java | 6 +
.../org/apache/hudi/util/TestFlinkClientUtil.java | 22 ++
.../apache/hudi/configuration/FlinkOptions.java | 11 +
.../hudi/configuration/HadoopConfigurations.java | 4 +-
.../configuration/TestHadoopConfigurations.java | 272 +++++++++++++++++++++
.../apache/hudi/table/ITTestHoodieDataSource.java | 202 +++++++++++++++
8 files changed, 560 insertions(+), 31 deletions(-)
diff --git
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkTableServiceClient.java
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkTableServiceClient.java
index 8ec43d9781d4..4d295ad9b50b 100644
---
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkTableServiceClient.java
+++
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/client/HoodieFlinkTableServiceClient.java
@@ -18,7 +18,6 @@
package org.apache.hudi.client;
-import org.apache.hudi.client.common.HoodieFlinkEngineContext;
import org.apache.hudi.client.embedded.EmbeddedTimelineService;
import org.apache.hudi.common.engine.HoodieEngineContext;
import org.apache.hudi.common.model.HoodieCommitMetadata;
@@ -37,7 +36,6 @@ import org.apache.hudi.exception.HoodieClusteringException;
import org.apache.hudi.exception.HoodieCommitException;
import org.apache.hudi.exception.HoodieException;
import org.apache.hudi.exception.HoodieIOException;
-import org.apache.hudi.hadoop.fs.HadoopFSUtils;
import org.apache.hudi.metadata.FlinkHoodieBackedTableMetadataWriter;
import org.apache.hudi.metadata.HoodieBackedTableMetadataWriter;
import org.apache.hudi.storage.StorageConfiguration;
@@ -46,7 +44,6 @@ import org.apache.hudi.table.HoodieTable;
import org.apache.hudi.table.action.HoodieWriteMetadata;
import org.apache.hudi.table.action.compact.CompactHelpers;
import org.apache.hudi.table.marker.WriteMarkersFactory;
-import org.apache.hudi.util.FlinkClientUtil;
import lombok.extern.slf4j.Slf4j;
@@ -187,7 +184,7 @@ public class HoodieFlinkTableServiceClient<T> extends
BaseHoodieTableServiceClie
*/
private HoodieBackedTableMetadataWriter initMetadataWriter(Option<String>
latestPendingInstant) {
return (HoodieBackedTableMetadataWriter)
FlinkHoodieBackedTableMetadataWriter.create(
- HadoopFSUtils.getStorageConf(FlinkClientUtil.getHadoopConf()),
this.config, HoodieFlinkEngineContext.DEFAULT, latestPendingInstant);
+ storageConf, this.config, context, latestPendingInstant);
}
public void initMetadataTable() {
diff --git
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/FlinkClientUtil.java
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/FlinkClientUtil.java
index d4e67507b6ae..baa8fe42de7a 100644
---
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/FlinkClientUtil.java
+++
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/util/FlinkClientUtil.java
@@ -24,6 +24,7 @@ import org.apache.hudi.common.table.HoodieTableConfig;
import org.apache.hudi.common.table.HoodieTableMetaClient;
import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.exception.HoodieValidationException;
import org.apache.hudi.hadoop.fs.HadoopFSUtils;
import org.apache.flink.api.java.hadoop.mapred.utils.HadoopUtils;
@@ -35,6 +36,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.File;
+import java.util.Arrays;
import java.util.Map;
/**
@@ -44,6 +46,7 @@ public class FlinkClientUtil {
private static final Logger LOG =
LoggerFactory.getLogger(FlinkClientUtil.class);
public static String FLINK_HADOOP_CONFIG_PREFIX = "flink.hadoop";
public static String FLINK_CONF_DIR = "FLINK_CONF_DIR";
+ private static final String[] HADOOP_SITE_FILES = {"core-site.xml",
"hdfs-site.xml", "yarn-site.xml", "mapred-site.xml"};
/**
* Creates the meta client.
@@ -70,6 +73,19 @@ public class FlinkClientUtil {
hadoopConf = new org.apache.hadoop.conf.Configuration();
}
+ return applyFlinkHadoopOptions(hadoopConf);
+ }
+
+ /**
+ * Loads an explicit Hadoop configuration directory, then applies the Hadoop
options from
+ * FLINK_CONF_DIR, as in environment-based discovery. Invalid explicit
directories fail fast.
+ */
+ public static org.apache.hadoop.conf.Configuration getHadoopConf(String
hadoopConfDir) {
+ validateHadoopConfDir(hadoopConfDir);
+ return applyFlinkHadoopOptions(getHadoopConfiguration(hadoopConfDir));
+ }
+
+ private static org.apache.hadoop.conf.Configuration
applyFlinkHadoopOptions(org.apache.hadoop.conf.Configuration hadoopConf) {
String confDir = System.getenv(FLINK_CONF_DIR);
if (!StringUtils.isNullOrEmpty(confDir)) {
LOG.info("Flink conf dir: {}", confDir);
@@ -86,35 +102,36 @@ public class FlinkClientUtil {
return hadoopConf;
}
- /**
- * Returns a new Hadoop Configuration object using the path to the hadoop
conf configured.
- *
- * @param hadoopConfDir Hadoop conf directory path.
- * @return A Hadoop configuration instance.
- */
+ private static void validateHadoopConfDir(String hadoopConfDir) {
+ if (hadoopConfDir == null || hadoopConfDir.trim().isEmpty()) {
+ throw new HoodieValidationException("'hadoop_conf.dir' must not be
empty. Leave it unset to use environment discovery");
+ }
+ File dir = new File(hadoopConfDir);
+ if (!dir.isDirectory()) {
+ throw new HoodieValidationException("Invalid 'hadoop_conf.dir':
directory does not exist or is not a directory: " + hadoopConfDir);
+ }
+ boolean hasSiteFile = Arrays.stream(HADOOP_SITE_FILES)
+ .map(name -> new File(dir, name))
+ .anyMatch(File::isFile);
+ if (!hasSiteFile) {
+ throw new HoodieValidationException("Invalid 'hadoop_conf.dir': no
supported Hadoop site files "
+ + "(core-site.xml, hdfs-site.xml, yarn-site.xml, mapred-site.xml)
found under directory: " + hadoopConfDir);
+ }
+ }
+
private static org.apache.hadoop.conf.Configuration
getHadoopConfiguration(String hadoopConfDir) {
- if (new File(hadoopConfDir).exists()) {
- org.apache.hadoop.conf.Configuration hadoopConfiguration = new
org.apache.hadoop.conf.Configuration();
- File coreSite = new File(hadoopConfDir, "core-site.xml");
- if (coreSite.exists()) {
- hadoopConfiguration.addResource(new Path(coreSite.getAbsolutePath()));
- }
- File hdfsSite = new File(hadoopConfDir, "hdfs-site.xml");
- if (hdfsSite.exists()) {
- hadoopConfiguration.addResource(new Path(hdfsSite.getAbsolutePath()));
- }
- File yarnSite = new File(hadoopConfDir, "yarn-site.xml");
- if (yarnSite.exists()) {
- hadoopConfiguration.addResource(new Path(yarnSite.getAbsolutePath()));
- }
- // Add mapred-site.xml. We need to read configurations like compression
codec.
- File mapredSite = new File(hadoopConfDir, "mapred-site.xml");
- if (mapredSite.exists()) {
- hadoopConfiguration.addResource(new
Path(mapredSite.getAbsolutePath()));
+ File dir = new File(hadoopConfDir);
+ if (!dir.exists()) {
+ return null;
+ }
+ org.apache.hadoop.conf.Configuration hadoopConf = new
org.apache.hadoop.conf.Configuration();
+ for (String name : HADOOP_SITE_FILES) {
+ File site = new File(dir, name);
+ if (site.exists()) {
+ hadoopConf.addResource(new Path(site.getAbsolutePath()));
}
- return hadoopConfiguration;
}
- return null;
+ return hadoopConf;
}
/**
diff --git
a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java
b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java
index 7ddacba98a66..7f9ffeacae47 100644
---
a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java
+++
b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/client/TestHoodieFlinkTableServiceClient.java
@@ -64,6 +64,8 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -112,7 +114,11 @@ public class TestHoodieFlinkTableServiceClient extends
HoodieFlinkClientTestHarn
writerFactory.when(() ->
FlinkHoodieBackedTableMetadataWriter.create(any(), any(), any(), any()))
.thenReturn(metadataWriter);
+ context.getStorageConf().set("dfs.nameservices", "remote-cluster");
tableServiceClient.initMetadataTable();
+ writerFactory.verify(() -> FlinkHoodieBackedTableMetadataWriter.create(
+ argThat(conf ->
"remote-cluster".equals(conf.getString("dfs.nameservices").orElse(null))),
+ same(writeConfig), same(context), any()));
} finally {
tableServiceClient.close();
}
diff --git
a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestFlinkClientUtil.java
b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestFlinkClientUtil.java
index cc4df7c103e4..abdd5d6b050d 100644
---
a/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestFlinkClientUtil.java
+++
b/hudi-client/hudi-flink-client/src/test/java/org/apache/hudi/util/TestFlinkClientUtil.java
@@ -21,9 +21,13 @@ package org.apache.hudi.util;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.configuration.GlobalConfiguration;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
import static org.apache.hudi.util.FlinkClientUtil.FLINK_HADOOP_CONFIG_PREFIX;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
@@ -48,4 +52,22 @@ public class TestFlinkClientUtil {
assertEquals(CRYPTO_RETRIEVER_CLASS,
hadoopConf.get(CRYPTO_RETRIEVER_KEY));
}
}
+
+ @Test
+ public void testExplicitDirectoryPreservesFlinkHadoopOptions(@TempDir Path
dir) throws Exception {
+ Files.writeString(dir.resolve("core-site.xml"),
"<configuration><property><name>fs.defaultFS</name>"
+ + "<value>hdfs://remote-cluster</value></property><property><name>" +
CRYPTO_RETRIEVER_KEY
+ + "</name><value>site-value</value></property></configuration>");
+ try (MockedStatic<GlobalConfiguration> global =
Mockito.mockStatic(GlobalConfiguration.class)) {
+ Configuration flinkConf = new Configuration();
+ flinkConf.setString(FLINK_CRYPTO_RETRIEVER_KEY, CRYPTO_RETRIEVER_CLASS);
+ flinkConf.setString(FLINK_HADOOP_CONFIG_PREFIX + "test.marker",
"flink-only");
+ global.when(() ->
GlobalConfiguration.loadConfiguration(anyString())).thenReturn(flinkConf);
+ org.apache.hadoop.conf.Configuration hadoopConf =
FlinkClientUtil.getHadoopConf(dir.toString());
+ assertEquals("hdfs://remote-cluster", hadoopConf.get("fs.defaultFS"));
+ assertEquals(CRYPTO_RETRIEVER_CLASS,
hadoopConf.get(CRYPTO_RETRIEVER_KEY));
+ assertEquals("flink-only", hadoopConf.get("test.marker"));
+ }
+ }
+
}
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java
index 6ec2adea7116..e0758e447731 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/FlinkOptions.java
@@ -1535,6 +1535,17 @@ public class FlinkOptions extends HoodieConfig {
+ "Each task manager will create a unique subdirectory under
this path. "
+ "The directory is cleaned up when the lookup function is
closed.");
+ @AdvancedConfig
+ public static final ConfigOption<String> HADOOP_CONF_DIR = ConfigOptions
+ .key("hadoop_conf.dir")
+ .stringType()
+ .noDefaultValue()
+ .withDescription("Local Hadoop configuration directory for cross-cluster
reads and writes. "
+ + "It must contain at least one readable core-site.xml,
hdfs-site.xml, yarn-site.xml or mapred-site.xml "
+ + "and be available at the same path on the client, JobManager and
TaskManagers. "
+ + "Invalid explicit directories fail fast; when unset, environment
discovery is used. "
+ + "Hadoop properties from FLINK_CONF_DIR override site files, and
table-level hadoop.* options override both.");
+
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
diff --git
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java
index 12d920ac9870..0a10338e8183 100644
---
a/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java
+++
b/hudi-flink-datasource/hudi-flink/src/main/java/org/apache/hudi/configuration/HadoopConfigurations.java
@@ -49,7 +49,9 @@ public class HadoopConfigurations {
* along with some configurations necessary to construct file
readers/writers.
*/
public static org.apache.hadoop.conf.Configuration
getHadoopConf(Configuration conf) {
- org.apache.hadoop.conf.Configuration hadoopConf =
FlinkClientUtil.getHadoopConf();
+ String hadoopConfDir =
conf.getOptional(FlinkOptions.HADOOP_CONF_DIR).orElse(null);
+ org.apache.hadoop.conf.Configuration hadoopConf = hadoopConfDir == null
+ ? FlinkClientUtil.getHadoopConf() :
FlinkClientUtil.getHadoopConf(hadoopConfDir);
Map<String, String> hadoopOptions =
FlinkOptions.getPropertiesWithPrefix(conf.toMap(), HADOOP_PREFIX);
hadoopOptions.forEach(hadoopConf::set);
// kind of hacky: flink specific IO options.
diff --git
a/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestHadoopConfigurations.java
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestHadoopConfigurations.java
new file mode 100644
index 000000000000..078c05cb0b62
--- /dev/null
+++
b/hudi-flink-datasource/hudi-flink/src/test/java/org/apache/hudi/configuration/TestHadoopConfigurations.java
@@ -0,0 +1,272 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.configuration;
+
+import org.apache.hudi.exception.HoodieValidationException;
+
+import org.apache.flink.configuration.Configuration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Test cases for {@link HadoopConfigurations#getHadoopConf(Configuration)},
+ * covering the {@code hadoop_conf.dir} option used for cross-cluster access.
+ */
+public class TestHadoopConfigurations {
+
+ @TempDir
+ File tempDir;
+
+ // -------------------------------------------------------------------------
+ // Helpers
+
+ private File createHadoopConfDir(String... siteFileContents) throws
IOException {
+ File confDir = new File(tempDir, "hadoop-conf-" + System.nanoTime());
+ assertTrue(confDir.mkdirs(), "Failed to create hadoop conf dir");
+ for (String content : siteFileContents) {
+ // content format: "filename|xml-body"
+ String[] parts = content.split("\\|", 2);
+ writeSiteFile(new File(confDir, parts[0]), parts[1]);
+ }
+ return confDir;
+ }
+
+ private void writeSiteFile(File file, String content) throws IOException {
+ try (FileWriter writer = new FileWriter(file)) {
+ writer.write(content);
+ }
+ }
+
+ private static String siteXml(String name, String value) {
+ return "<?xml version=\"1.0\"?>\n"
+ + "<configuration>\n"
+ + " <property>\n"
+ + " <name>" + name + "</name>\n"
+ + " <value>" + value + "</value>\n"
+ + " </property>\n"
+ + "</configuration>\n";
+ }
+
+ // -------------------------------------------------------------------------
+ // Load explicit configuration directories
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void testGetHadoopConfLoadsCoreSiteFromConfDir() throws Exception {
+ File confDir = createHadoopConfDir(
+ "core-site.xml|" + siteXml("fs.defaultFS",
"hdfs://remote-cluster:8020"));
+
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(),
confDir.getAbsolutePath());
+
+ org.apache.hadoop.conf.Configuration hadoopConf =
+ HadoopConfigurations.getHadoopConf(conf);
+
+ assertEquals("hdfs://remote-cluster:8020", hadoopConf.get("fs.defaultFS"),
+ "fs.defaultFS should be loaded from the specified hadoop conf dir");
+ }
+
+ @Test
+ public void testGetHadoopConfLoadsHdfsSiteFromConfDir() throws Exception {
+ File confDir = createHadoopConfDir(
+ "hdfs-site.xml|" + siteXml("dfs.nameservices", "remote-ns"));
+
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(),
confDir.getAbsolutePath());
+
+ org.apache.hadoop.conf.Configuration hadoopConf =
+ HadoopConfigurations.getHadoopConf(conf);
+
+ assertEquals("remote-ns", hadoopConf.get("dfs.nameservices"),
+ "hdfs-site.xml properties should be loaded from the specified conf
dir");
+ }
+
+ @Test
+ public void testGetHadoopConfMergesMultipleSiteFiles() throws Exception {
+ File confDir = createHadoopConfDir(
+ "core-site.xml|" + siteXml("fs.defaultFS",
"hdfs://remote-cluster:8020"),
+ "hdfs-site.xml|" + siteXml("dfs.nameservices", "remote-ns"),
+ "yarn-site.xml|" + siteXml("yarn.resourcemanager.address",
"remote-rm:8032"));
+
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(),
confDir.getAbsolutePath());
+
+ org.apache.hadoop.conf.Configuration hadoopConf =
+ HadoopConfigurations.getHadoopConf(conf);
+
+ assertEquals("hdfs://remote-cluster:8020", hadoopConf.get("fs.defaultFS"));
+ assertEquals("remote-ns", hadoopConf.get("dfs.nameservices"));
+ assertEquals("remote-rm:8032",
hadoopConf.get("yarn.resourcemanager.address"));
+ }
+
+ // -------------------------------------------------------------------------
+ // Table-level Hadoop option precedence
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void testIndividualHadoopOptionsOverrideConfDir() throws Exception {
+ File confDir = createHadoopConfDir(
+ "core-site.xml|" + siteXml("fs.defaultFS",
"hdfs://remote-cluster:8020"));
+
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(),
confDir.getAbsolutePath());
+ // Individual hadoop.* option should take the highest precedence
+ conf.setString("hadoop.fs.defaultFS", "hdfs://override-cluster:8020");
+
+ org.apache.hadoop.conf.Configuration hadoopConf =
+ HadoopConfigurations.getHadoopConf(conf);
+
+ assertEquals("hdfs://override-cluster:8020",
hadoopConf.get("fs.defaultFS"),
+ "hadoop.* prefixed options should override values from
hadoop_conf.dir");
+ }
+
+ @Test
+ public void testHadoopConfDirKeyNotLeakedIntoHadoopConf() throws Exception {
+ File confDir = createHadoopConfDir(
+ "core-site.xml|" + siteXml("fs.defaultFS",
"hdfs://remote-cluster:8020"));
+
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(),
confDir.getAbsolutePath());
+
+ org.apache.hadoop.conf.Configuration hadoopConf =
+ HadoopConfigurations.getHadoopConf(conf);
+
+ // 'hadoop_conf.dir' uses underscore separator and never matches the
'hadoop.'
+ // prefix, so it must not be forwarded as a Hadoop property.
+ assertNull(hadoopConf.get("conf.dir"),
+ "hadoop_conf.dir should not leak into Hadoop conf as 'conf.dir'");
+ assertNull(hadoopConf.get("hadoop_conf.dir"),
+ "hadoop_conf.dir is a connector option, not a Hadoop property");
+ }
+
+ // -------------------------------------------------------------------------
+ // Reject invalid explicit configuration directories
+ // -------------------------------------------------------------------------
+
+ @Test
+ public void testGetHadoopConfWithNonExistentConfDir() {
+ String nonExistentDir = new File(tempDir,
"does-not-exist").getAbsolutePath();
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(), nonExistentDir);
+
+ HoodieValidationException e = assertThrows(HoodieValidationException.class,
+ () -> HadoopConfigurations.getHadoopConf(conf),
+ "Explicitly configured non-existent dir should fail fast");
+
+ assertTrue(e.getMessage().contains(nonExistentDir),
+ "Error message should contain the invalid conf dir path");
+ assertTrue(e.getMessage().contains(FlinkOptions.HADOOP_CONF_DIR.key()),
+ "Error message should mention the option key");
+ }
+
+ @Test
+ public void testGetHadoopConfWithConfDirBeingAFile() throws Exception {
+ // Path exists but is a regular file, not a directory
+ File file = new File(tempDir, "not-a-dir");
+ assertTrue(file.createNewFile());
+
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(), file.getAbsolutePath());
+
+ HoodieValidationException e = assertThrows(HoodieValidationException.class,
+ () -> HadoopConfigurations.getHadoopConf(conf));
+
+ assertTrue(e.getMessage().contains(file.getAbsolutePath()));
+ }
+
+ @Test
+ public void testGetHadoopConfWithConfDirMissingSiteFiles() {
+ // Directory exists but contains no *-site.xml — still a config error.
+ // An empty Configuration would silently fall back fs.defaultFS to local
FS.
+ File emptyDir = new File(tempDir, "empty-conf-dir");
+ assertTrue(emptyDir.mkdirs());
+
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(),
emptyDir.getAbsolutePath());
+
+ HoodieValidationException e = assertThrows(HoodieValidationException.class,
+ () -> HadoopConfigurations.getHadoopConf(conf));
+
+ assertTrue(e.getMessage().contains(emptyDir.getAbsolutePath()));
+ assertTrue(e.getMessage().contains("core-site.xml"),
+ "Error message should mention expected site files");
+ }
+
+ @Test
+ public void testGetHadoopConfWithEmptyStringConfDir() {
+ // Explicitly set to empty string is also a configuration error
+ Configuration conf = new Configuration();
+ conf.setString(FlinkOptions.HADOOP_CONF_DIR.key(), "");
+
+ assertThrows(HoodieValidationException.class,
+ () -> HadoopConfigurations.getHadoopConf(conf));
+ }
+
+ // Unconfigured options — environment discovery fallback
+ @Test
+ public void testGetHadoopConfWithoutConfDirFallsBackToEnvironment() {
+ // Without the option, getHadoopConf delegates to FlinkClientUtil
+ // environment discovery — must not throw, and hadoop.* overrides
+ // should still be applied on top.
+ Configuration conf = new Configuration();
+ conf.setString("hadoop.test.marker", "marker-value");
+
+ org.apache.hadoop.conf.Configuration hadoopConf =
+ HadoopConfigurations.getHadoopConf(conf);
+
+ assertNotNull(hadoopConf);
+ assertEquals("marker-value", hadoopConf.get("test.marker"),
+ "hadoop.* prefixed options should be forwarded even without
hadoop_conf.dir");
+ }
+
+ @Test
+ public void testUnsupportedSiteFileDoesNotValidateDirectory() throws
Exception {
+ File dir = createHadoopConfDir("hive-site.xml|" + siteXml("fs.defaultFS",
"hdfs://ignored"));
+ Configuration conf = new Configuration();
+ conf.set(FlinkOptions.HADOOP_CONF_DIR, dir.getAbsolutePath());
+ assertThrows(HoodieValidationException.class, () ->
HadoopConfigurations.getHadoopConf(conf));
+ }
+
+ @Test
+ public void testSiteFileMustBeRegularFile() throws Exception {
+ File dir = createHadoopConfDir();
+ assertTrue(new File(dir, "core-site.xml").mkdir());
+ Configuration conf = new Configuration();
+ conf.set(FlinkOptions.HADOOP_CONF_DIR, dir.getAbsolutePath());
+ assertThrows(HoodieValidationException.class, () ->
HadoopConfigurations.getHadoopConf(conf));
+ }
+
+ @Test
+ public void testWhitespaceConfDirFails() {
+ Configuration conf = new Configuration();
+ conf.set(FlinkOptions.HADOOP_CONF_DIR, " ");
+ assertThrows(HoodieValidationException.class, () ->
HadoopConfigurations.getHadoopConf(conf));
+ }
+
+}
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 5740a5e2e6d3..c877fda22097 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
@@ -37,6 +37,7 @@ import org.apache.hudi.common.util.CollectionUtils;
import org.apache.hudi.config.HoodieWriteConfig;
import org.apache.hudi.configuration.FlinkOptions;
import org.apache.hudi.configuration.HadoopConfigurations;
+import org.apache.hudi.hadoop.fs.NonLocalSchemeLocalFileSystem;
import org.apache.hudi.index.HoodieIndex;
import org.apache.hudi.index.bucket.partition.PartitionBucketIndexUtils;
import org.apache.hudi.metadata.HoodieTableMetadata;
@@ -86,6 +87,7 @@ import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.File;
+import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -3670,6 +3672,156 @@ public class ITTestHoodieDataSource {
+ "+I[id8, Han, 56, 1970-01-01T00:00:08, par4]]");
}
+ // -------------------------------------------------------------------------
+ // Cross-cluster access via hadoop_conf.dir
+ // -------------------------------------------------------------------------
+
+ /**
+ * Batch write then batch read, with 'hadoop_conf.dir' pointing at a conf
+ * directory that registers a non-local filesystem backed by local files.
+ */
+ @Test
+ public void testWriteAndReadWithHadoopConfDir() throws Exception {
+ File hadoopConfDir = createHadoopConfDirWithRemoteFs();
+
+ StoragePath path = new StoragePath("viewfs://remote-cluster" +
tempFile.getAbsolutePath());
+
+ // ---- write ----
+ TableEnvironment writeEnv = batchTableEnv;
+ String writeDDL = sql("source")
+ .option(FlinkOptions.PATH, path)
+ .option(FlinkOptions.HADOOP_CONF_DIR, hadoopConfDir.getAbsolutePath())
+ .option(FlinkOptions.METADATA_ENABLED, true)
+ .end();
+ writeEnv.executeSql(writeDDL);
+ writeEnv.executeSql(
+ "insert into source values "
+ + "('id1','Danny',23,TIMESTAMP '1970-01-01 00:00:01','par1'),"
+ + "('id2','Stephen',33,TIMESTAMP '1970-01-01
00:00:02','par1')")
+ .await();
+
+ // ---- read ----
+ String readDDL = sql("t1")
+ .option(FlinkOptions.PATH, path)
+ .option(FlinkOptions.HADOOP_CONF_DIR, hadoopConfDir.getAbsolutePath())
+ .option(FlinkOptions.METADATA_ENABLED, true)
+ .end();
+ batchTableEnv.executeSql(readDDL);
+
+ List<Row> result = CollectionUtil.iterableToList(
+ () -> batchTableEnv.sqlQuery("select * from t1").execute().collect());
+ assertRowsEquals(result, "[+I[id1, Danny, 23, 1970-01-01T00:00:01, par1], "
+ + "+I[id2, Stephen, 33, 1970-01-01T00:00:02, par1]]");
+ }
+
+ /**
+ * Incremental read with 'hadoop_conf.dir': the read path must use the
+ * configured conf dir (via HoodieTableSource /
StreamReadMonitoringFunction),
+ * not the environment-discovered configuration.
+ */
+ @Test
+ public void testIncrementalReadWithHadoopConfDir() throws Exception {
+ File hadoopConfDir = createHadoopConfDirWithRemoteFs();
+ StoragePath path = new StoragePath("viewfs://remote-cluster" +
tempFile.getAbsolutePath());
+ Configuration conf = TestConfigurations.getDefaultConf(path.toString());
+ conf.set(FlinkOptions.HADOOP_CONF_DIR, hadoopConfDir.getAbsolutePath());
+ TestData.writeData(TestData.dataSetInsert(1, 2), conf);
+ TestData.writeData(TestData.dataSetInsert(3, 4), conf);
+ String secondCommit =
StreamerUtil.createMetaClient(conf).getCommitsTimeline()
+ .filterCompletedInstants().lastInstant().get().requestedTime();
+
+ String ddl = sql("t1")
+ .option(FlinkOptions.PATH, path)
+ .option(FlinkOptions.QUERY_TYPE, FlinkOptions.QUERY_TYPE_INCREMENTAL)
+ .option(FlinkOptions.READ_START_COMMIT, secondCommit)
+ .option(FlinkOptions.HADOOP_CONF_DIR, hadoopConfDir.getAbsolutePath())
+ .end();
+ batchTableEnv.executeSql(ddl);
+
+ List<Row> result = CollectionUtil.iterableToList(
+ () -> batchTableEnv.sqlQuery("select * from t1").execute().collect());
+ assertRowsEquals(result, TestData.dataSetInsert(3, 4));
+ }
+
+ /**
+ * 'hadoop_conf.dir' pointing at a non-existent directory must fail fast —
+ * silently falling back to the environment configuration would resolve
+ * fs.defaultFS to the local filesystem instead of the intended cluster.
+ */
+ @Test
+ public void testReadWithInvalidHadoopConfDirFails() {
+ StoragePath path = new StoragePath(tempFile.toURI());
+ String nonExistentDir = new File(tempFile,
"no-such-conf-dir").getAbsolutePath();
+
+ String ddl = sql("t1")
+ .option(FlinkOptions.PATH, path)
+ .option(FlinkOptions.HADOOP_CONF_DIR, nonExistentDir)
+ .end();
+
+ // The table source validates the conf dir when the catalog/planner
+ // instantiates it (DDL registration or first query planning).
+ batchTableEnv.executeSql(ddl);
+ Throwable e = assertThrows(Exception.class,
+ () -> batchTableEnv.sqlQuery("select * from
t1").execute().collect().next());
+ assertTrue(getRootMessage(e).contains(nonExistentDir)
+ && getRootMessage(e).contains(FlinkOptions.HADOOP_CONF_DIR.key()),
+ "Error should name the invalid conf dir and the option key, got: "
+ + getRootMessage(e));
+ }
+
+ /**
+ * An existing directory that contains no Hadoop site files is also a
+ * configuration error — an empty Configuration would again resolve to the
+ * local filesystem.
+ */
+ @Test
+ public void testReadWithEmptyHadoopConfDirFails() {
+ StoragePath path = new StoragePath(tempFile.toURI());
+ File emptyDir = new File(tempFile, "empty-conf-dir");
+ assertTrue(emptyDir.mkdirs());
+ String ddl = sql("t1")
+ .option(FlinkOptions.PATH, path)
+ .option(FlinkOptions.HADOOP_CONF_DIR, emptyDir.getAbsolutePath())
+ .end();
+
+ batchTableEnv.executeSql(ddl);
+ Throwable e = assertThrows(Exception.class,
+ () -> batchTableEnv.sqlQuery("select * from
t1").execute().collect().next());
+ assertTrue(getRootMessage(e).contains(emptyDir.getAbsolutePath()),
+ "Error should name the invalid conf dir, got: " + getRootMessage(e));
+ }
+
+ /**
+ * 'hadoop.*' single-property options take precedence over the site files
+ * loaded from 'hadoop_conf.dir'.
+ */
+ @Test
+ public void testHadoopOptionOverridesConfDir() throws Exception {
+ File hadoopConfDir = createHadoopConfDirWithRemoteFs();
+ StoragePath path = new StoragePath(tempFile.toURI());
+ writeSiteXml(new File(hadoopConfDir, "core-site.xml"),
+ "test.prop", "site-value");
+
+ String ddl = sql("t1")
+ .option(FlinkOptions.PATH, path)
+ .option(FlinkOptions.HADOOP_CONF_DIR, hadoopConfDir.getAbsolutePath())
+ .option("hadoop.test.prop", "override-value")
+ .end();
+ batchTableEnv.executeSql(ddl);
+
+ // verify via HadoopConfigurations directly since the DDL path only
+ // exercises the conf inside the table source
+ Configuration flinkConf = new Configuration();
+ flinkConf.setString(FlinkOptions.HADOOP_CONF_DIR.key(),
+ hadoopConfDir.getAbsolutePath());
+ flinkConf.setString("hadoop.test.prop", "override-value");
+
+ org.apache.hadoop.conf.Configuration hadoopConf =
+ HadoopConfigurations.getHadoopConf(flinkConf);
+ assertEquals("override-value", hadoopConf.get("test.prop"),
+ "hadoop.* options must override values from the conf dir");
+ }
+
@ParameterizedTest
@MethodSource("indexAndPartitioningParams")
void testWriteMultipleCommitWithDifferentLogBlockType(String indexType,
boolean hiveStylePartitioning) throws Exception {
@@ -4358,6 +4510,56 @@ public class ITTestHoodieDataSource {
.collect(Collectors.toList());
}
+ /** Local-backed remote filesystem with eagerly loaded permissions for
recursive listings. */
+ public static class ConfDirFileSystem extends NonLocalSchemeLocalFileSystem {
+ @Override
+ public org.apache.hadoop.fs.FileStatus
getFileStatus(org.apache.hadoop.fs.Path path) throws IOException {
+ org.apache.hadoop.fs.FileStatus status = super.getFileStatus(path);
+ // RawLocalFileSystem loads permissions lazily using java.io.File, which
requires a file URI.
+ status.setPath(new org.apache.hadoop.fs.Path(new
File(path.toUri().getPath()).toURI()));
+ org.apache.hadoop.fs.FileStatus copy = new
org.apache.hadoop.fs.FileStatus(status);
+ copy.setPath(makeQualified(path));
+ return copy;
+ }
+ }
+
+ /** Registers a non-local filesystem that cannot be resolved without the
supplied configuration. */
+ private File createHadoopConfDirWithRemoteFs() throws IOException {
+ File dir = new File(tempFile, "remote-hadoop-conf");
+ assertTrue(dir.mkdirs());
+ org.apache.hadoop.conf.Configuration remoteConf = new
org.apache.hadoop.conf.Configuration(false);
+ remoteConf.set("fs.viewfs.impl", ConfDirFileSystem.class.getName());
+ remoteConf.setBoolean("fs.viewfs.impl.disable.cache", true);
+ try (FileWriter writer = new FileWriter(new File(dir, "core-site.xml"))) {
+ remoteConf.writeXml(writer);
+ }
+ writeSiteXml(new File(dir, "hdfs-site.xml"), "dfs.replication", "1");
+ return dir;
+ }
+
+ private static void writeSiteXml(File file, String name, String value)
+ throws IOException {
+ String content = "<?xml version=\"1.0\"?>\n"
+ + "<configuration>\n"
+ + " <property><name>" + name + "</name><value>" + value
+ + "</value></property>\n"
+ + "</configuration>\n";
+ try (FileWriter w = new FileWriter(file)) {
+ w.write(content);
+ }
+ }
+
+ private static String getRootMessage(Throwable t) {
+ StringBuilder sb = new StringBuilder();
+ while (t != null) {
+ if (t.getMessage() != null) {
+ sb.append(t.getMessage()).append("; ");
+ }
+ t = t.getCause();
+ }
+ return sb.toString();
+ }
+
private List<Row> fetchResultWithExpectedNum(TableEnvironment tEnv,
TableResult tableResult) {
try {
// wait the continuous streaming query to be terminated by forced
exception with expected row number