This is an automated email from the ASF dual-hosted git repository.
wombatu-kun 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 42913d08459c fix(utilities): Re-add _corrupt_record when a transformer
drops it in ErrorTableAwareChainedTransformer (#20336)
42913d08459c is described below
commit 42913d08459c53edad0aea674234358bed6be9d3
Author: Raghuramchadalawada
<[email protected]>
AuthorDate: Thu Oct 22 18:44:07 2026 -0700
fix(utilities): Re-add _corrupt_record when a transformer drops it in
ErrorTableAwareChainedTransformer (#20336)
* fix(utilities): Re-add _corrupt_record when a transformer drops it in
ErrorTableAwareChainedTransformer
Custom transformers that project columns drop _corrupt_record because they
are unaware of the error-table contract, and
ErrorTableAwareChainedTransformer
failed the pipeline with HoodieValidationException after any such
transformer.
Re-add the column as null when a transformer drops it while it holds no
values. It is known to be all null until the source supplies it or a
transformer keeps it; otherwise one Spark job checks the transformer input.
If the dropped column held values, still fail, since those error records
cannot be realigned with the transformer output and re-adding null would
write them to the target table.
Closes #18915
* Simplify ErrorTableAwareChainedTransformer loop and extract
dropped-column validation
* Simplify corrupt record flag handling and extract test helpers in
ErrorTableAwareChainedTransformer
---
.../hudi/utilities/streamer/ErrorTableUtils.java | 14 +-----
.../ErrorTableAwareChainedTransformer.java | 40 ++++++++++++++--
.../TestErrorTableAwareChainedTransformer.java | 53 ++++++++++++++++++++++
3 files changed, 89 insertions(+), 18 deletions(-)
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorTableUtils.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorTableUtils.java
index 1904346f593a..fe2e44a2440f 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorTableUtils.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/streamer/ErrorTableUtils.java
@@ -27,7 +27,6 @@ import org.apache.hudi.common.util.StringUtils;
import org.apache.hudi.common.util.ValidationUtils;
import org.apache.hudi.config.HoodieErrorTableConfig;
import org.apache.hudi.exception.HoodieException;
-import org.apache.hudi.exception.HoodieValidationException;
import org.apache.hudi.utilities.ingestion.HoodieIngestionMetrics;
import org.apache.hadoop.fs.FileSystem;
@@ -79,17 +78,6 @@ public final class ErrorTableUtils {
return
HoodieErrorTableConfig.ErrorWriteFailureStrategy.valueOf(writeFailureStrategy);
}
- /**
- * validates for constraints on ErrorRecordColumn when ErrorTable enabled
configs are set.
- * @param dataset
- */
- public static void validate(Dataset<Row> dataset) {
- if (!isErrorTableCorruptRecordColumnPresent(dataset)) {
- throw new HoodieValidationException(String.format("Invalid condition,
columnName=%s "
- + "is not present in transformer " + "output schema",
ERROR_TABLE_CURRUPT_RECORD_COL_NAME));
- }
- }
-
public static Dataset<Row>
addNullValueErrorTableCorruptRecordColumn(Dataset<Row> dataset) {
if (!isErrorTableCorruptRecordColumnPresent(dataset)) {
dataset = dataset.withColumn(ERROR_TABLE_CURRUPT_RECORD_COL_NAME,
lit(null));
@@ -97,7 +85,7 @@ public final class ErrorTableUtils {
return dataset;
}
- private static boolean isErrorTableCorruptRecordColumnPresent(Dataset<Row>
dataset) {
+ public static boolean isErrorTableCorruptRecordColumnPresent(Dataset<Row>
dataset) {
return Arrays.stream(dataset.columns()).anyMatch(col ->
col.equals(ERROR_TABLE_CURRUPT_RECORD_COL_NAME));
}
}
diff --git
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/ErrorTableAwareChainedTransformer.java
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/ErrorTableAwareChainedTransformer.java
index a7d2979e90f0..29dc421de34f 100644
---
a/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/ErrorTableAwareChainedTransformer.java
+++
b/hudi-utilities/src/main/java/org/apache/hudi/utilities/transform/ErrorTableAwareChainedTransformer.java
@@ -22,9 +22,11 @@ package org.apache.hudi.utilities.transform;
import org.apache.hudi.common.config.TypedProperties;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.util.Option;
+import org.apache.hudi.exception.HoodieValidationException;
import org.apache.hudi.utilities.streamer.ErrorTableUtils;
import org.apache.spark.api.java.JavaSparkContext;
+import org.apache.spark.sql.Column;
import org.apache.spark.sql.Dataset;
import org.apache.spark.sql.Row;
import org.apache.spark.sql.SparkSession;
@@ -33,10 +35,14 @@ import org.apache.spark.sql.types.StructType;
import java.util.List;
import java.util.function.Supplier;
+import static
org.apache.hudi.utilities.streamer.BaseErrorTableWriter.ERROR_TABLE_CURRUPT_RECORD_COL_NAME;
+
/**
* A {@link Transformer} to chain other {@link Transformer}s and apply
sequentially.
- * Adds errorTableCorruptRecordColumn at the beginning of transformations and
validates
- * if that column is not dropped in any of the transformations.
+ * Adds errorTableCorruptRecordColumn at the beginning of transformations and
re-adds it as null
+ * if a transformer drops it (e.g. a custom column-projecting transformer).
Dropping the column is
+ * rejected only if it held values, since those error records cannot be
realigned with the
+ * transformer output and would otherwise be written to the target table.
*/
public class ErrorTableAwareChainedTransformer extends ChainedTransformer {
public ErrorTableAwareChainedTransformer(List<String>
configuredTransformers, Supplier<Option<HoodieSchema>> sourceSchemaSupplier) {
@@ -51,16 +57,40 @@ public class ErrorTableAwareChainedTransformer extends
ChainedTransformer {
public Dataset<Row> apply(JavaSparkContext jsc, SparkSession sparkSession,
Dataset<Row> rowDataset,
TypedProperties properties) {
Dataset<Row> dataset = rowDataset;
+ // Until the source supplies the column or a transformer keeps it, it is
known to be all null.
+ boolean corruptRecordMayHaveValues =
ErrorTableUtils.isErrorTableCorruptRecordColumnPresent(dataset);
dataset =
ErrorTableUtils.addNullValueErrorTableCorruptRecordColumn(dataset);
for (TransformerInfo transformerInfo : transformers) {
Transformer transformer = transformerInfo.getTransformer();
- dataset = transformer.apply(jsc, sparkSession, dataset,
transformerInfo.getProperties(properties, transformers));
- // validate in every stage to ensure ErrorRecordColumn not dropped by
one of the transformer and added by next transformer.
- ErrorTableUtils.validate(dataset);
+ Dataset<Row> input = dataset;
+ dataset = transformer.apply(jsc, sparkSession, input,
transformerInfo.getProperties(properties, transformers));
+ if (ErrorTableUtils.isErrorTableCorruptRecordColumnPresent(dataset)) {
+ corruptRecordMayHaveValues = true;
+ } else {
+ if (corruptRecordMayHaveValues) {
+ validateNoErrorRecordsDropped(input, transformer);
+ }
+ // The column held no values, so re-adding it as null loses nothing.
+ dataset =
ErrorTableUtils.addNullValueErrorTableCorruptRecordColumn(dataset);
+ corruptRecordMayHaveValues = false;
+ }
}
return dataset;
}
+ /**
+ * Fails if {@code transformer} dropped a {@code _corrupt_record} column
that held values in its input. Those error
+ * records cannot be realigned with the transformer output, and re-adding
the column as null would write them to
+ * the target table.
+ */
+ private static void validateNoErrorRecordsDropped(Dataset<Row> input,
Transformer transformer) {
+ if (!input.filter(new
Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME).isNotNull()).isEmpty()) {
+ throw new HoodieValidationException(String.format("Transformer %s
dropped column %s, which holds error records "
+ + "marked by the source or an earlier transformer. Keep the column
in its output, or apply it before the "
+ + "transformers that mark error records.",
transformer.getClass().getName(), ERROR_TABLE_CURRUPT_RECORD_COL_NAME));
+ }
+ }
+
@Override
public StructType transformedSchema(JavaSparkContext jsc, SparkSession
sparkSession, StructType incomingStruct, TypedProperties properties) {
return super.transformedSchema(jsc, sparkSession, incomingStruct,
properties);
diff --git
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestErrorTableAwareChainedTransformer.java
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestErrorTableAwareChainedTransformer.java
index b2e9414417ba..c528b6836155 100644
---
a/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestErrorTableAwareChainedTransformer.java
+++
b/hudi-utilities/src/test/java/org/apache/hudi/utilities/functional/TestErrorTableAwareChainedTransformer.java
@@ -52,6 +52,7 @@ import static org.apache.spark.sql.types.DataTypes.StringType;
import static org.apache.spark.sql.types.DataTypes.createStructField;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("functional")
public class TestErrorTableAwareChainedTransformer extends
SparkClientFunctionalTestHarness {
@@ -89,6 +90,58 @@ public class TestErrorTableAwareChainedTransformer extends
SparkClientFunctional
assertThrows(HoodieValidationException.class, () ->
transformer.apply(jsc(), spark(), original, properties));
}
+ @Test
+ void testColumnProjectionReAddsNullCorruptRecordColumn() {
+ // A custom transformer projecting columns drops _corrupt_record while it
is still all null.
+ Dataset<Row> transformed = applyWithErrorTableEnabled(getTestDataset(),
getErrorRecordColumnDropTransformer());
+ assertCorruptRecordColumnAllNull(transformed);
+ }
+
+ @Test
+ void testColumnProjectionAfterTransformerKeepingNullCorruptRecordColumn() {
+ // t1 keeps _corrupt_record without marking rows, so t2 may drop it.
+ Transformer t1 = (jsc, sparkSession, dataset, props) ->
dataset.withColumn("foo", dataset.col("foo").cast(IntegerType));
+ Transformer t2 = getErrorRecordColumnDropTransformer();
+ Dataset<Row> transformed = applyWithErrorTableEnabled(getTestDataset(),
t1, t2);
+ assertCorruptRecordColumnAllNull(transformed);
+ }
+
+ @Test
+ void testErrorRecordsMarkedAfterColumnProjection() {
+ // t1 drops _corrupt_record, t2 marks error records in the re-added column.
+ Transformer t1 = getErrorRecordColumnDropTransformer();
+ Transformer t2 = getErrorEventHandlerTransformer();
+ Dataset<Row> transformed = applyWithErrorTableEnabled(getTestDataset(),
t1, t2);
+
+ assertArrayEquals(new String[]{"foo",
ERROR_TABLE_CURRUPT_RECORD_COL_NAME}, transformed.columns());
+ assertEquals(2, transformed.filter(new
Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME).equalTo("true")).count());
+ }
+
+ @Test
+ void testColumnProjectionWithSourceSuppliedCorruptRecordColumn() {
+ // The source supplies _corrupt_record: dropping it is allowed only while
it holds no values.
+ Transformer columnFilter = getErrorRecordColumnDropTransformer();
+
+ Dataset<Row> withoutErrors =
getTestDataset().withColumn(ERROR_TABLE_CURRUPT_RECORD_COL_NAME,
functions.lit(null).cast(StringType));
+ assertCorruptRecordColumnAllNull(applyWithErrorTableEnabled(withoutErrors,
columnFilter));
+
+ Dataset<Row> withErrors =
getTestDataset().withColumn(ERROR_TABLE_CURRUPT_RECORD_COL_NAME,
+ functions.when(new Column("foo").equalTo("100"), functions.lit("{bad
json")).otherwise(functions.lit(null).cast(StringType)));
+ HoodieValidationException e =
assertThrows(HoodieValidationException.class, () ->
applyWithErrorTableEnabled(withErrors, columnFilter));
+ assertTrue(e.getMessage().contains(ERROR_TABLE_CURRUPT_RECORD_COL_NAME));
+ }
+
+ private Dataset<Row> applyWithErrorTableEnabled(Dataset<Row> input,
Transformer... transformers) {
+ TypedProperties properties = new TypedProperties();
+ properties.setProperty(ERROR_TABLE_ENABLED.key(), "true");
+ return new
ErrorTableAwareChainedTransformer(Arrays.asList(transformers)).apply(jsc(),
spark(), input, properties);
+ }
+
+ private static void assertCorruptRecordColumnAllNull(Dataset<Row>
transformed) {
+ assertArrayEquals(new String[]{"foo",
ERROR_TABLE_CURRUPT_RECORD_COL_NAME}, transformed.columns());
+ assertEquals(2, transformed.filter(new
Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME).isNull()).count());
+ }
+
private Dataset<Row> getTestDataset() {
StructType schema = DataTypes.createStructType(
new StructField[]{