-
Notifications
You must be signed in to change notification settings - Fork 2.5k
fix(utilities): Re-inject _corrupt_record in ErrorTableAwareChainedTransformer after each transformer #18914
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
42ae216
99653cd
f069a12
fbcbd05
33995af
be2d9ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,7 @@ | |
| 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 +34,14 @@ | |
| 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 preserves | ||
| * its values across transformers that drop it (e.g. custom column-projecting transformers). | ||
| * Values are stashed before each transformer and restored via positional RDD zip if the | ||
| * transformer dropped the column. | ||
| */ | ||
| public class ErrorTableAwareChainedTransformer extends ChainedTransformer { | ||
| public ErrorTableAwareChainedTransformer(List<String> configuredTransformers, Supplier<Option<HoodieSchema>> sourceSchemaSupplier) { | ||
|
|
@@ -54,9 +59,29 @@ public Dataset<Row> apply(JavaSparkContext jsc, SparkSession sparkSession, Datas | |
| dataset = ErrorTableUtils.addNullValueErrorTableCorruptRecordColumn(dataset); | ||
| for (TransformerInfo transformerInfo : transformers) { | ||
| Transformer transformer = transformerInfo.getTransformer(); | ||
|
|
||
| // Stash _corrupt_record values before the transformer can drop them | ||
| Dataset<Row> corruptRecordStash = null; | ||
| if (ErrorTableUtils.isErrorTableCorruptRecordColumnPresent(dataset)) { | ||
| corruptRecordStash = dataset.select(new Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME)); | ||
| corruptRecordStash.cache(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 The stash is |
||
| } | ||
|
|
||
| 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); | ||
|
|
||
| if (!ErrorTableUtils.isErrorTableCorruptRecordColumnPresent(dataset)) { | ||
| if (corruptRecordStash != null) { | ||
| // Restore original values via positional zip — works when the transformer | ||
| // only projects columns (row count and partition layout unchanged). | ||
| dataset = ErrorTableUtils.restoreCorruptRecordColumn(sparkSession, dataset, corruptRecordStash); | ||
| } else { | ||
| dataset = ErrorTableUtils.addNullValueErrorTableCorruptRecordColumn(dataset); | ||
| } | ||
| } | ||
|
|
||
| if (corruptRecordStash != null) { | ||
| corruptRecordStash.unpersist(); | ||
| } | ||
| } | ||
| return dataset; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,6 @@ | |
|
|
||
| import org.apache.hudi.common.config.TypedProperties; | ||
| import org.apache.hudi.common.util.Option; | ||
| import org.apache.hudi.exception.HoodieValidationException; | ||
| import org.apache.hudi.testutils.SparkClientFunctionalTestHarness; | ||
| import org.apache.hudi.utilities.exception.HoodieTransformException; | ||
| import org.apache.hudi.utilities.transform.ErrorTableAwareChainedTransformer; | ||
|
|
@@ -76,17 +75,62 @@ public void testForErrorTableConfig() { | |
| } | ||
|
|
||
| @Test | ||
| public void testForErrorRecordColumn() { | ||
| void testCorruptRecordPreservedAfterTransformerDropsIt() { | ||
| // Regression for ENG-41958: t1 populates _corrupt_record with "true", t2 drops the | ||
| // column via select("foo"). The chain must preserve the populated values, not re-inject | ||
| // as null — otherwise error rows silently flow into the main write path instead of the | ||
| // error table. | ||
| Dataset<Row> original = getTestDataset(); | ||
|
|
||
| Transformer t1 = getErrorEventHandlerTransformer(); | ||
| Transformer t2 = getErrorRecordColumnDropTransformer(); | ||
| Transformer t3 = (jsc, sparkSession, dataset, properties) -> dataset.withColumn("foo", | ||
| Transformer t3 = (jsc, sparkSession, dataset, props) -> dataset.withColumn("foo", | ||
| dataset.col("foo").cast(IntegerType)); | ||
| TypedProperties properties = new TypedProperties(); | ||
| properties.setProperty(ERROR_TABLE_ENABLED.key(), "true"); | ||
| ErrorTableAwareChainedTransformer transformer = new ErrorTableAwareChainedTransformer(Arrays.asList(t1, t2, t3)); | ||
| assertThrows(HoodieValidationException.class, () -> transformer.apply(jsc(), spark(), original, properties)); | ||
| Dataset<Row> transformed = transformer.apply(jsc(), spark(), original, properties); | ||
|
|
||
| assertArrayEquals(new String[]{"foo", ERROR_TABLE_CURRUPT_RECORD_COL_NAME}, transformed.columns()); | ||
| assertEquals(2, transformed.count()); | ||
| // Values populated by t1 must survive t2 dropping the column | ||
| assertEquals(0, transformed.filter(new Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME).isNull()).count()); | ||
| assertEquals(2, transformed.filter(new Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME).equalTo("true")).count()); | ||
| } | ||
|
|
||
| @Test | ||
| void testCustomColumnProjectionPreservesCorruptRecord() { | ||
| // Single custom transformer doing column projection — drops _corrupt_record. | ||
| Dataset<Row> original = getTestDataset(); | ||
|
|
||
| Transformer columnFilter = (jsc, sparkSession, dataset, props) -> dataset.select("foo"); | ||
| TypedProperties properties = new TypedProperties(); | ||
| properties.setProperty(ERROR_TABLE_ENABLED.key(), "true"); | ||
| ErrorTableAwareChainedTransformer transformer = | ||
| new ErrorTableAwareChainedTransformer(Arrays.asList(columnFilter)); | ||
| Dataset<Row> transformed = transformer.apply(jsc(), spark(), original, properties); | ||
|
|
||
| assertArrayEquals(new String[]{"foo", ERROR_TABLE_CURRUPT_RECORD_COL_NAME}, transformed.columns()); | ||
| assertEquals(2, transformed.count()); | ||
| assertEquals(2, transformed.filter(new Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME).isNull()).count()); | ||
| } | ||
|
|
||
| @Test | ||
| void testTransformerPreservingCorruptRecordIsNoOp() { | ||
| // Transformer that keeps all columns — re-injection is a no-op. | ||
| Dataset<Row> original = getTestDataset(); | ||
|
|
||
| Transformer keepAll = (jsc, sparkSession, dataset, props) -> dataset.withColumn("foo", | ||
| dataset.col("foo").cast(IntegerType)); | ||
| TypedProperties properties = new TypedProperties(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 nit: the lambda parameter here is named - AI-generated; verify before applying. React 👍/👎 to flag quality.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. +1
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressing this one as well. |
||
| properties.setProperty(ERROR_TABLE_ENABLED.key(), "true"); | ||
| ErrorTableAwareChainedTransformer transformer = | ||
| new ErrorTableAwareChainedTransformer(Arrays.asList(keepAll)); | ||
| Dataset<Row> transformed = transformer.apply(jsc(), spark(), original, properties); | ||
|
|
||
| assertArrayEquals(new String[]{"foo", ERROR_TABLE_CURRUPT_RECORD_COL_NAME}, transformed.columns()); | ||
| assertEquals(2, transformed.count()); | ||
| assertEquals(2, transformed.filter(new Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME).isNull()).count()); | ||
| } | ||
|
|
||
| private Dataset<Row> getTestDataset() { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤖 The
zip/map/createDataFramechain here is all lazy — no Spark action runs inside thistry, so the zip's failure (unequal partition counts throwIllegalArgumentExceptionfromgetPartitions, unequal per-partition element counts throwSparkExceptionfrom the iterator) is raised later at action time in the caller, not here. That means thecatch+ WARN fallback toaddNullValueErrorTableCorruptRecordColumnwon't trigger for the filter/repartition case it's meant to cover, and the pipeline crashes downstream instead. Could you force the alignment eagerly (e.g. comparetransformed.rdd().getNumPartitions()and cached counts, or trigger the zip within the try) so the fallback actually kicks in?