fix(utilities): Re-inject _corrupt_record in ErrorTableAwareChainedTransformer after each transformer - #18914
Conversation
…former after each transformer Custom JAR transformers that do column projection (e.g. ColumnFilter with mode=include) drop _corrupt_record since they are unaware of the error-table contract. Replace the validate() call with addNullValueErrorTableCorruptRecordColumn() after each transformer.apply(), so any transformer — built-in or custom — can safely project columns without breaking the quarantine pipeline.
The validate() method is no longer called after replacing it with addNullValueErrorTableCorruptRecordColumn() in the previous commit. Remove the dead code and its HoodieValidationException import.
hudi-agent
left a comment
There was a problem hiding this comment.
🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.
Thanks for the contribution! This PR loosens ErrorTableAwareChainedTransformer so that custom column-projecting transformers no longer crash the chain by dropping _corrupt_record — instead the column is re-injected as null after each stage. One semantic concern worth confirming in the inline comment around silent loss of populated error data. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. One minor naming inconsistency in the new tests — the lambda parameter in testTransformerPreservingCorruptRecordIsNoOp could use props to match the adjacent test and avoid visual confusion with the outer TypedProperties properties variable.
| ErrorTableUtils.validate(dataset); | ||
| // Re-inject _corrupt_record if the transformer dropped it (e.g. custom JAR transformers | ||
| // that do column projection like ColumnFilter with mode=include). | ||
| dataset = ErrorTableUtils.addNullValueErrorTableCorruptRecordColumn(dataset); |
There was a problem hiding this comment.
🤖 If an earlier transformer in the chain populates _corrupt_record with actual error rows and a later transformer drops it, this silently re-injects it as a null column — the previously captured error data is lost without warning. Previously validate() would have surfaced this as an exception. Is that an acceptable tradeoff for the column-projection use case, or worth at least logging a warn when the column was dropped after being non-null? @yihua
- AI-generated; verify before applying. React 👍/👎 to flag quality.
There was a problem hiding this comment.
Confirmed reachable in production: StreamSync applies the chain then calls processErrorEvents with CUSTOM_TRANSFORMER_FAILURE, and that extraction in SourceFormatAdapter keys off _corrupt_record being non-null. If an earlier transformer marks rows and a later one projects the column away, re-injecting as null here makes every row match the isNull filter in processErrorEvents, so the marked rows flow into the main write path - not just dropped from the error table but silently written to the target table. The new test testCorruptRecordReInjectedAfterTransformerDropsIt sets up exactly this populate-then-drop case (t1 marks, t2 drops) yet only asserts column presence and count, so it passes whether the marked data survives or not. Safer to extract the column before re-injecting, or at minimum WARN when a non-null column is dropped and assert the row outcome in that test.
There was a problem hiding this comment.
Made changes, Testing them.
|
|
||
| Transformer keepAll = (jsc, sparkSession, dataset, properties) -> dataset.withColumn("foo", | ||
| dataset.col("foo").cast(IntegerType)); | ||
| TypedProperties properties = new TypedProperties(); |
There was a problem hiding this comment.
🤖 nit: the lambda parameter here is named properties, while the sibling test testCustomColumnProjectionPreservesCorruptRecord uses props for the same position — could you use props here too for consistency? It also avoids the visual confusion with TypedProperties properties declared two lines below.
- AI-generated; verify before applying. React 👍/👎 to flag quality.
There was a problem hiding this comment.
Addressing this one as well.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #18914 +/- ##
============================================
+ Coverage 68.77% 68.80% +0.02%
- Complexity 29073 29099 +26
============================================
Files 2517 2517
Lines 139807 139820 +13
Branches 17210 17205 -5
============================================
+ Hits 96148 96197 +49
+ Misses 35885 35851 -34
+ Partials 7774 7772 -2
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Previously re-injected _corrupt_record as null after each transformer, silently losing error data populated by earlier transformers. Stash values before each transformer and restore via positional RDD zip if the column was dropped. Falls back to null re-injection with a WARN if the transformer changed row count or partitioning. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hudi-agent
left a comment
There was a problem hiding this comment.
Thanks for the follow-up! This revision replaces the null re-injection with a stash-and-restore approach: _corrupt_record values are cached before each transformer and re-attached via positional RDD zip if a transformer drops the column, preserving populated error markers. Both of my prior comments are addressed — the silent-data-loss concern is resolved by the new restore path, and the properties/props naming nit is fixed. One correctness concern remains on the new restoreCorruptRecordColumn: because the zip is built lazily, the try/catch fallback likely won't catch zip failures at the point it expects (see the inline comment). Please take a look at the inline comment, and this should be ready for a Hudi committer or PMC member to take it from here.
| * than the null-re-injection alternative, but callers should be aware. | ||
| * | ||
| * @param sparkSession the active Spark session | ||
| * @param transformed the dataset after the transformer ran (missing {@code _corrupt_record}) |
There was a problem hiding this comment.
🤖 The zip/map/createDataFrame chain here is all lazy — no Spark action runs inside this try, so the zip's failure (unequal partition counts throw IllegalArgumentException from getPartitions, unequal per-partition element counts throw SparkException from the iterator) is raised later at action time in the caller, not here. That means the catch + WARN fallback to addNullValueErrorTableCorruptRecordColumn won'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. compare transformed.rdd().getNumPartitions() and cached counts, or trigger the zip within the try) so the fallback actually kicks in?
The zip/map/createDataFrame chain is entirely lazy — no Spark action runs inside the try block, so partition or element count mismatches surface downstream instead of hitting the catch + WARN fallback. Add result.count() to force eager evaluation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
hudi-agent
left a comment
There was a problem hiding this comment.
Thanks for working on this! The PR replaces the hard validate() check with a stash-and-restore approach so column-projecting transformers no longer crash the error-table pipeline — the _corrupt_record values are stashed before each transformer and re-attached via a positional RDD zip if dropped. The direction is reasonable, but the correctness of the positional zip hinges on both branches seeing identical row ordering; one edge case worth double-checking is flagged inline. Please take a look at any inline comments, and this should be ready for a Hudi committer or PMC member to take it from here.
| Dataset<Row> corruptRecordStash = null; | ||
| if (ErrorTableUtils.isErrorTableCorruptRecordColumnPresent(dataset)) { | ||
| corruptRecordStash = dataset.select(new Column(ERROR_TABLE_CURRUPT_RECORD_COL_NAME)); | ||
| corruptRecordStash.cache(); |
There was a problem hiding this comment.
🤖 The stash is cache()d but never materialized with an action before transformer.apply runs, so both stash and transformed recompute the shared dataset lineage independently at zip time. If that upstream lineage is non-deterministic in row order (e.g. an earlier transformer did a shuffle/repartition, or the source doesn't guarantee stable ordering), the two recomputations can order rows differently within a partition — the zip still succeeds and result.count() still matches, but _corrupt_record values get attached to the wrong rows. Could you force materialization of the stash (e.g. a .count() after .cache()) — or better, restore via a monotonic join key rather than positional zip? @nsivabalan could you sanity-check the alignment assumption here?
Describe the issue this Pull Request addresses
Closes #18915
Summary and Changelog
Custom JAR transformers that do column projection (e.g.
ColumnFilterwithmode=include) drop_corrupt_recordsince they are unaware of the error-table contract.ErrorTableAwareChainedTransformercalledvalidate()after every transformer, causingHoodieValidationExceptionfor any transformer that projects the column away.validate()withaddNullValueErrorTableCorruptRecordColumn()after eachtransformer.apply()so the column is re-injected if droppedvalidate()method andHoodieValidationExceptionimport fromErrorTableUtilsImpact
No public API change. Behavior change: transformers that drop
_corrupt_recordno longer crash the pipeline — the column is silently re-added as null.Risk Level
low — the fix is a one-line swap in the transformer chain loop, and
addNullValueErrorTableCorruptRecordColumn()is already used at the start of the same method.Documentation Update
none
Contributor's checklist