This is an automated email from the ASF dual-hosted git repository.
voonhous 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 b6c4677206b4 feat: Add source path field in table created from S3/GCS
source (#14257)
b6c4677206b4 is described below
commit b6c4677206b4ba241c4b9b85110da60fcec7e1f3
Author: Roushan Kumar <[email protected]>
AuthorDate: Mon Aug 17 13:47:24 2026 +0530
feat: Add source path field in table created from S3/GCS source (#14257)
* feat: Add source path field in table created from S3/GCS source
* address comments - move config to cloud source config
* fix config
* Use Slf4j log field instead of LOG in CloudObjectsSelectorCommon
* Add missing java.io.File import in TestCloudObjectsSelectorCommon
* review(14257): address round-1 review
- make the source path column nullable: input_file_name() is non-nullable,
which
produced a required Avro field with no default and blocked enabling the
flag on
an existing table (READER_FIELD_MISSING_DEFAULT_VALUE); Spark's "unknown
file"
empty string now maps to null
- rename the column to _hoodie_cloud_source_path, matching the
_hoodie_<source>_source_* convention of the Kafka and Kinesis sources, and
expose the constant
- append via withColumn (replace-if-present) like the path-based partition
columns
instead of prepending via selectExpr behind a hard-fail collision guard;
the
guard also blocked declaring the column in a schema provider's schema,
which is
required to keep it (Source.fetchNext replaces the row-derived provider
with
the configured one, and SourceFormatAdapter rewrites rows to its schema)
- warn when a schema provider is configured and its schema lacks the column
- add sinceVersion and document the column name, value format
(percent-encoded
URI), overwrite semantics, the schema-provider caveat and the one-way door
- drop the unrelated, incomplete config-prefix refactor in CloudSourceConfig
- tests: parameterize only partitionValueAddedToRow and
loadDatasetWithSchemaAndRepartition; replace the two collision tests and
their
fixtures with one test pinning the percent-encoded value, the overwrite
and the
nullable-with-default avro field; drop the hadoop Path import and the
unrelated
visibility churn
---------
Co-authored-by: Lokesh Jain <[email protected]>
Co-authored-by: voon <[email protected]>
---
.../hudi/utilities/config/CloudSourceConfig.java | 14 +++
.../helpers/CloudObjectsSelectorCommon.java | 16 +++
.../helpers/TestCloudObjectsSelectorCommon.java | 122 ++++++++++++++++++---
3 files changed, 136 insertions(+), 16 deletions(-)
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
index 46393d64fa32..e2242e6a3c87 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/config/CloudSourceConfig.java
@@ -191,6 +191,20 @@ public class CloudSourceConfig extends HoodieConfig {
.sinceVersion("1.0.0")
.withDocumentation("Boolean value to allow coalesce alias columns with
actual columns while reading from source");
+ public static final ConfigProperty<Boolean> INCLUDE_SOURCE_PATH_FIELD =
ConfigProperty
+ .key(STREAMER_CONFIG_PREFIX +
"source.cloud.data.include.source.path.field")
+ .defaultValue(false)
+ .markAdvanced()
+ .sinceVersion("1.3.0")
+ .withDocumentation("When enabled, appends a nullable string column named
_hoodie_cloud_source_path holding the "
+ + "fully-qualified URI of the source file each record was read from,
as returned by Spark's "
+ + "input_file_name() (percent-encoded, e.g.
s3a://bucket/dir/file%20name.json). A same-named column "
+ + "already present in the dataset is overwritten. If a schema
provider is configured "
+ + "(hoodie.streamer.schemaprovider.class), declare the column in its
schema (the source schema for the "
+ + "file-based and schema-registry providers) or it is dropped before
the write, with or without a "
+ + "transformer. Once enabled on a table, disabling it fails schema
validation on the next sync unless "
+ + "hoodie.write.set.null.for.missing.columns is enabled.");
+
public static final ConfigProperty<Boolean> CLOUD_INCREMENTAL_MERGE_SCHEMA =
ConfigProperty
.key(STREAMER_CONFIG_PREFIX + "source.cloud.data.merge.schema.enable")
.defaultValue(true)
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelectorCommon.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelectorCommon.java
index 8a572044e5ab..c5a9a6e7a923 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelectorCommon.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/sources/helpers/CloudObjectsSelectorCommon.java
@@ -86,6 +86,7 @@ import static
org.apache.hudi.utilities.config.S3EventsHoodieIncrSourceConfig.S3
import static
org.apache.hudi.utilities.sources.helpers.IncrSourceHelper.coalesceOrRepartition;
import static org.apache.spark.sql.functions.input_file_name;
import static org.apache.spark.sql.functions.split;
+import static org.apache.spark.sql.functions.when;
/**
* Generic helper methods to fetch from Cloud Storage during incremental fetch
from cloud storage buckets.
@@ -100,6 +101,7 @@ public class CloudObjectsSelectorCommon {
public static final String S3_BUCKET_NAME = "s3.bucket.name";
public static final String GCS_OBJECT_KEY = "name";
public static final String GCS_OBJECT_SIZE = "size";
+ public static final String CLOUD_SOURCE_PATH_COLUMN =
"_hoodie_cloud_source_path";
private static final String SPACE_DELIMTER = " ";
private static final String GCS_PREFIX = "gs://";
@@ -343,6 +345,20 @@ public class CloudObjectsSelectorCommon {
dataset = dataset.withColumn(partitionKey,
split(split(input_file_name(), partitionPathPattern).getItem(1),
StoragePath.SEPARATOR).getItem(0));
}
}
+
+ // append the source file path if configured. input_file_name() is
non-nullable, so wrap it to make the
+ // column nullable (required to add it to an existing table); the wrapper
also maps Spark's "unknown file"
+ // empty string to null. Overwrites a same-named column, matching the
partition columns above.
+ if (getBooleanWithAltKeys(properties,
CloudSourceConfig.INCLUDE_SOURCE_PATH_FIELD)) {
+ if (rowSchema != null &&
!Arrays.asList(rowSchema.fieldNames()).contains(CLOUD_SOURCE_PATH_COLUMN)) {
+ // the streamer rewrites row sources to the configured schema
provider's schema before writing
+ log.warn("Column {} is not declared in the configured schema
provider's schema; it will be dropped before "
+ + "the write unless it is declared there",
CLOUD_SOURCE_PATH_COLUMN);
+ }
+ log.info("Adding column {} to dataset", CLOUD_SOURCE_PATH_COLUMN);
+ dataset = dataset.withColumn(CLOUD_SOURCE_PATH_COLUMN,
when(input_file_name().notEqual(""), input_file_name()));
+ }
+
dataset = coalesceOrRepartition(dataset, numPartitions);
return Option.of(dataset);
}
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestCloudObjectsSelectorCommon.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestCloudObjectsSelectorCommon.java
index e4d1e42cabef..f7cb663fa320 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestCloudObjectsSelectorCommon.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/sources/helpers/TestCloudObjectsSelectorCommon.java
@@ -21,9 +21,12 @@ package org.apache.hudi.utilities.sources.helpers;
import org.apache.hudi.HoodieSchemaConversionUtils;
import org.apache.hudi.common.config.TypedProperties;
import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.schema.HoodieSchemaField;
import org.apache.hudi.common.util.Option;
import org.apache.hudi.testutils.HoodieSparkClientTestHarness;
+import org.apache.hudi.utilities.config.CloudSourceConfig;
import org.apache.hudi.utilities.schema.FilebasedSchemaProvider;
+import org.apache.hudi.utilities.schema.RowBasedSchemaProvider;
import org.apache.spark.sql.Dataset;
import org.apache.spark.sql.Row;
@@ -36,10 +39,15 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.Path;
+import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
@@ -80,18 +88,21 @@ public class TestCloudObjectsSelectorCommon extends
HoodieSparkClientTestHarness
Assertions.assertEquals(Collections.singletonList(expected),
result.get().collectAsList());
}
- @Test
- public void partitionValueAddedToRow() {
- List<CloudObjectMetadata> input = Collections.singletonList(new
CloudObjectMetadata("src/test/resources/data/partitioned/country=US/state=CA/data.json",
1));
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void partitionValueAddedToRow(boolean includeSourcePathField) {
+ String dataPath =
"src/test/resources/data/partitioned/country=US/state=CA/data.json";
+ List<CloudObjectMetadata> input = Collections.singletonList(new
CloudObjectMetadata(dataPath, 1));
TypedProperties properties = new TypedProperties();
properties.put("hoodie.streamer.source.cloud.data.partition.fields.from.path",
"country,state");
+ setIncludeSourcePathField(properties, includeSourcePathField);
CloudObjectsSelectorCommon cloudObjectsSelectorCommon = new
CloudObjectsSelectorCommon(properties);
Option<Dataset<Row>> result =
cloudObjectsSelectorCommon.loadAsDataset(sparkSession, input, "json",
Option.empty(), 1);
+
Assertions.assertTrue(result.isPresent());
- Assertions.assertEquals(1, result.get().count());
- Row expected = RowFactory.create("some data", "US", "CA");
- Assertions.assertEquals(Collections.singletonList(expected),
result.get().collectAsList());
+ assertRowResult(includeSourcePathField,
Collections.singletonList(dataPath), result.get(),
+ new Object[]{"some data", "US", "CA"});
}
@Test
@@ -129,27 +140,38 @@ public class TestCloudObjectsSelectorCommon extends
HoodieSparkClientTestHarness
Assertions.assertEquals(Collections.singletonList(expected),
result.get().collectAsList());
}
- @Test
- public void loadDatasetWithSchemaAndRepartition() {
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void loadDatasetWithSchemaAndRepartition(boolean includeSourcePathField) {
TypedProperties props = new TypedProperties();
-
TestCloudObjectsSelectorCommon.class.getClassLoader().getResource("schema/sample_data_schema.avsc");
String schemaFilePath =
TestCloudObjectsSelectorCommon.class.getClassLoader().getResource("schema/sample_data_schema.avsc").getPath();
props.put("hoodie.streamer.schemaprovider.source.schema.file",
schemaFilePath);
props.put("hoodie.streamer.schema.provider.class.name",
FilebasedSchemaProvider.class.getName());
props.put("hoodie.streamer.source.cloud.data.partition.fields.from.path",
"country,state");
// Setting this config so that dataset repartition happens inside
`loadAsDataset`
props.put("hoodie.streamer.source.cloud.data.partition.max.size", "1");
+ setIncludeSourcePathField(props, includeSourcePathField);
+
+ String dataPath1 =
"src/test/resources/data/partitioned/country=US/state=CA/data.json";
+ String dataPath2 =
"src/test/resources/data/partitioned/country=US/state=TX/data.json";
+ String dataPath3 =
"src/test/resources/data/partitioned/country=IND/state=TS/data.json";
+
List<CloudObjectMetadata> input = Arrays.asList(
- new
CloudObjectMetadata("src/test/resources/data/partitioned/country=US/state=CA/data.json",
1000),
- new
CloudObjectMetadata("src/test/resources/data/partitioned/country=US/state=TX/data.json",
1000),
- new
CloudObjectMetadata("src/test/resources/data/partitioned/country=IND/state=TS/data.json",
1000)
- );
+ new CloudObjectMetadata(dataPath1, 1000),
+ new CloudObjectMetadata(dataPath2, 1000),
+ new CloudObjectMetadata(dataPath3, 1000));
+
CloudObjectsSelectorCommon cloudObjectsSelectorCommon = new
CloudObjectsSelectorCommon(props);
Option<Dataset<Row>> result =
cloudObjectsSelectorCommon.loadAsDataset(sparkSession, input, "json",
Option.of(new FilebasedSchemaProvider(props, jsc)), 30);
+
Assertions.assertTrue(result.isPresent());
- List<Row> expected = Arrays.asList(RowFactory.create("some data", "US",
"CA"), RowFactory.create("some data", "US", "TX"), RowFactory.create("some
data", "IND", "TS"));
- List<Row> actual = result.get().collectAsList();
- Assertions.assertEquals(new HashSet<>(expected), new HashSet<>(actual));
+ assertRowResult(
+ includeSourcePathField,
+ Arrays.asList(dataPath1, dataPath2, dataPath3),
+ result.get(),
+ new Object[]{"some data", "US", "CA"},
+ new Object[]{"some data", "US", "TX"},
+ new Object[]{"some data", "IND", "TS"});
}
@Test
@@ -275,4 +297,72 @@ public class TestCloudObjectsSelectorCommon extends
HoodieSparkClientTestHarness
Row expected = RowFactory.create("some data", null);
Assertions.assertEquals(Collections.singletonList(expected),
result.get().collectAsList());
}
+
+ @Test
+ void sourcePathColumnIsUriEncodedAndOverwritesExistingColumn(@TempDir Path
tempDir) throws IOException {
+ // file name with a space: input_file_name() returns the percent-encoded
URI, and the fixture already
+ // carries a same-named column that must be overwritten rather than
duplicated
+ Path dataFile = tempDir.resolve("we ird.json");
+ Files.write(dataFile, Collections.singletonList(
+ "{\"data\": \"some data\", \"" +
CloudObjectsSelectorCommon.CLOUD_SOURCE_PATH_COLUMN + "\":
\"existing/path\"}"));
+ TypedProperties properties = new TypedProperties();
+ setIncludeSourcePathField(properties, true);
+ CloudObjectsSelectorCommon cloudObjectsSelectorCommon = new
CloudObjectsSelectorCommon(properties);
+ List<CloudObjectMetadata> input = Collections.singletonList(new
CloudObjectMetadata(dataFile.toString(), 1));
+ Option<Dataset<Row>> result =
cloudObjectsSelectorCommon.loadAsDataset(sparkSession, input, "json",
Option.empty(), 1);
+
+ Assertions.assertTrue(result.isPresent());
+ String expectedPath = dataFile.toUri().toString();
+ Assertions.assertTrue(expectedPath.contains("%20"), expectedPath);
+ // JSON schema inference sorts the inferred fields by name, and
overwriting a column keeps its position,
+ // so the source path column stays first here instead of being appended
+
Assertions.assertEquals(Arrays.asList(CloudObjectsSelectorCommon.CLOUD_SOURCE_PATH_COLUMN,
"data"),
+ Arrays.asList(result.get().schema().fieldNames()));
+
Assertions.assertTrue(result.get().schema().apply(CloudObjectsSelectorCommon.CLOUD_SOURCE_PATH_COLUMN).nullable());
+ // the streamer derives the writer schema from the row schema; a
non-nullable field would be a required avro
+ // field without a default and could not be added to an existing table
+ HoodieSchemaField sourcePathField =
HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(
+ result.get().schema(),
RowBasedSchemaProvider.HOODIE_RECORD_STRUCT_NAME,
RowBasedSchemaProvider.HOODIE_RECORD_NAMESPACE)
+ .getField(CloudObjectsSelectorCommon.CLOUD_SOURCE_PATH_COLUMN).get();
+ Assertions.assertTrue(sourcePathField.isNullable());
+ Assertions.assertTrue(sourcePathField.hasDefaultValue());
+
Assertions.assertEquals(Collections.singletonList(RowFactory.create(expectedPath,
"some data")), result.get().collectAsList());
+ }
+
+ /**
+ * Asserts that a Dataset contains expected rows; when the source path
column is enabled it is expected
+ * to be appended last, nullable, and to hold the file URI of the row's
source file.
+ */
+ private static void assertRowResult(
+ boolean includeSourcePathField,
+ List<String> dataPaths,
+ Dataset<Row> actualResult,
+ Object[]... rowContents) {
+ Assertions.assertEquals(dataPaths.size(), rowContents.length, "dataPaths
and rowContents must align");
+ Assertions.assertEquals(rowContents.length, actualResult.count());
+ List<String> fieldNames =
Arrays.asList(actualResult.schema().fieldNames());
+
+ List<Row> expected = new ArrayList<>();
+ if (includeSourcePathField) {
+
Assertions.assertEquals(CloudObjectsSelectorCommon.CLOUD_SOURCE_PATH_COLUMN,
fieldNames.get(fieldNames.size() - 1));
+
Assertions.assertTrue(actualResult.schema().apply(CloudObjectsSelectorCommon.CLOUD_SOURCE_PATH_COLUMN).nullable());
+ for (int i = 0; i < dataPaths.size(); i++) {
+ List<Object> values = new ArrayList<>(Arrays.asList(rowContents[i]));
+ // input_file_name() returns the file URI, which java.nio's
Path.toUri() reproduces byte for byte
+ values.add(new
File(dataPaths.get(i)).getAbsoluteFile().toPath().toUri().toString());
+ expected.add(RowFactory.create(values.toArray()));
+ }
+ } else {
+
Assertions.assertFalse(fieldNames.contains(CloudObjectsSelectorCommon.CLOUD_SOURCE_PATH_COLUMN));
+ for (Object[] row : rowContents) {
+ expected.add(RowFactory.create(row));
+ }
+ }
+
+ Assertions.assertEquals(new HashSet<>(expected), new
HashSet<>(actualResult.collectAsList()));
+ }
+
+ private static void setIncludeSourcePathField(TypedProperties properties,
boolean include) {
+ properties.put(CloudSourceConfig.INCLUDE_SOURCE_PATH_FIELD.key(),
String.valueOf(include));
+ }
}