Skip to content

fix(avro): detect the two-field shredded variant shape and harden the variant merge path - #19620

Open
voonhous wants to merge 4 commits into
apache:masterfrom
voonhous:followup-19582-variant-merge-hardening
Open

fix(avro): detect the two-field shredded variant shape and harden the variant merge path#19620
voonhous wants to merge 4 commits into
apache:masterfrom
voonhous:followup-19582-variant-merge-hardening

Conversation

@voonhous

Copy link
Copy Markdown
Member

Describe the issue this Pull Request addresses

Follow-up to #19582 (fixes #19567), which merged as e1dd86e. Reviewing that PR again after it merged turned up one live detection gap, one latent crash it had fixed silently, a hot-path allocation, and two coverage holes. Nothing here changes the shape of the fix that merged; it closes the edges around it.

The detection gap is the substantive one. isShreddedVariantShape demanded a record of exactly three fields with a bytes value, but HoodieSchema.Variant.determineIfShredded - the answer used whenever the variant logical type survives - calls anything carrying typed_value shredded, whatever else is present. The two disagree, and it is the shape check that matters in practice: the parquet footer always strips the logical type, so on real files the shape check is the only detector that runs. The shredding spec lets a writer omit value when every row is typed, so a {metadata, typed_value} group read at the unshredded schema and lost its payload with no error - #19567 again, by another shape. Hudi's own writer always emits three fields, so the exposure is files written by another engine.

Summary and Changelog

  • isShreddedVariantShape now accepts {metadata: bytes, typed_value} with value optional, matching determineIfShredded. A struct carrying a fourth field is still rejected, and the requested-side variant anchor still does the false-positive work, so relaxing the count costs nothing: a plain user struct of the same shape is only a target when the requested column is a variant.
  • stripVariantShredding is now pinned by a test at the call site that can actually crash. Before fix(avro): detect shredded variant base files by shape so reconstruct… #19582 it re-added untouched fields by reference and handed them to Schema.setFields, which throws Field already used for any record of {other column + shredded variant} - the same defect feat(variant): support reading shredded variant base files via the AVRO reader #18938 fixed in the sibling HoodieVariantReconstruction.create, left behind in the twin. fix(avro): detect shredded variant base files by shape so reconstruct… #19582's recursive rewrite fixed it incidentally and silently; the guard lives on HoodieAvroWriteSupport.generateEffectiveSchema with shredding disabled, which is the clustering/compaction path that reaches it.
  • Both recursion loops built a full replacement field list and then discarded it when nothing matched. That is the path every non-variant table takes, on every runMerge and every avro parquet read, at the cost of an Avro Field plus a defaultVal() lookup per field per level. The list is now built lazily, backfilling only once a replacement appears.
  • The two COW small-file merge tests were near-identical copies pinned to AVRO. They are folded into one sweep over (record type, layout), which removes the copy, keeps the unshredded twin, and adds SPARK coverage that nothing had. Two repeated pins became assertVariantLayout / assertSingleFileGroup; the layout pin alone had been copy-pasted 11 times in that file.
  • The comments claiming nested reachability were overstated. They cited the row writer's depth recursion as the producer, but the forced-shredding hook is top-level only in BOTH write supports, so no DDL or table property reaches depth - only a hand-authored write schema can. The comments now say that, and say why the nested coverage is unit level rather than end to end.

Impact

Restores the shredded-variant payload for base files whose variant group omits the optional value column, which previously read back null through the AVRO path. No behaviour change for files Hudi wrote itself, which always carry all three fields.

Worth recording for anyone tracing this code, since it is not obvious and is not stated anywhere today: alignShreddedVariants can never fire for HoodieSparkParquetReader, because its getSchema() returns a nullable union rather than a record and the alignment bails at its RECORD/RECORD guard. The SPARK record type is therefore untouched by #19582 - verified by running the merge test on both record types with and without that fix: AVRO fails without it ([1,null,1000], [2,null,1000]) and passes with it, while SPARK passes identically either way. That scoping is a consequence of the guard rather than a deliberate decision, so the new SPARK leg of the sweep pins it instead of leaving it assumed.

Measured on a 500-column nested non-variant schema: alignShreddedVariants 730us -> 335us per runMerge, HoodieVariantReconstruction.create 834us -> 365us per parquet read. Small against file I/O, but it was pure waste on the common path.

Risk Level

low. The detection change only widens, and only for a shape that previously read back corrupt; the anchor that prevents false positives is untouched. The lazy build is a refactor of two loops whose output is asserted identical, including the same-instance contract the merge path depends on (assertSame in TestHoodieSchemaCompatibility). Verified on spark4.1/scala-2.13 end to end, plus hudi-common 371/371, TestHoodieVariantReconstruction 12/12 and the hudi-hadoop-common parquet suites.

Documentation Update

none

Contributor's checklist

  • Read through contributor's guide
  • Enough context is provided in the sections above
  • Adequate tests were added if applicable

…ility note

Two things the self-review turned up, both in code the round-3 commits
touched but no test or comment covered.

stripVariantShredding used to re-add untouched fields by reference and
then hand them to Schema.setFields, which throws "Field already used" for
any record of {other column + shredded variant}. That is the same defect
apache#18938 fixed in the sibling HoodieVariantReconstruction.create; the twin
here was missed. The recursive rewrite copies every field via withSchema
and so fixes it incidentally - now pinned by a test at the production
call site, HoodieAvroWriteSupport.generateEffectiveSchema with shredding
disabled, which is the clustering/compaction path that reaches it.

The nested comments claimed the row writer's depth recursion as the
producer of nested shredded files. That overstates reachability: the
forced-shredding hook is top-level only in BOTH write supports, so no DDL
or table property reaches depth and only a hand-authored write schema
can. Say that plainly instead, and note why the nested coverage is unit
level rather than end to end.
The shredded and unshredded small-file merge tests were near-identical
copies, and both were pinned to AVRO. Fold them into one sweep over
(record type, layout) and pull the two repeated pins into helpers.

What each leg is for:
- AVRO + shredded is the apache#19567 bug; it goes red without the
  HoodieMergeHelper alignment (verified by reverting that hunk: rows 1-2
  come back [1,null,1000], [2,null,1000]).
- AVRO + unshredded is the no-op guard, kept because a reviewer asked for
  the twin, now without the 65-line copy.
- SPARK is new coverage. HoodieSparkParquetReader.getSchema returns a
  nullable UNION rather than a RECORD, so alignShreddedVariants bails at
  its RECORD/RECORD guard and that reader is untouched by the fix. The
  leg passes identically with and without the fix; it is swept so that
  the guard which scopes the fix to the AVRO reader is pinned rather
  than merely assumed.

assertVariantLayout and assertSingleFileGroup also replace the inline
pins in the evolving-schema test; the layout pin was copy-pasted 11 times
across this file. Net -56 lines.

Verified on spark4.1/scala-2.13: 15 succeeded, 0 failed, 2 canceled (the
Spark 3.x-only tests).
…ilding schemas that do not change

Two things found self-reviewing apache#19582 after it merged.

isShreddedVariantShape demanded exactly three fields with a bytes `value`,
but HoodieSchema.Variant.determineIfShredded - the answer used whenever the
logical type survives - calls anything carrying typed_value shredded. The
shredding spec lets a writer omit `value` when every row is typed, and
because the parquet footer always strips the logical type, the shape check
is the only detector that runs on real files. So a two-field group read at
the unshredded schema and lost its payload silently: apache#19567 again by
another shape. Accept {metadata, typed_value} with `value` optional; the
requested-side variant anchor is what keeps plain user structs out, so this
costs no false positives, and a struct with a fourth field is still
rejected.

Second, both recursion loops copied every field into a new list and then
threw the list away when nothing matched. That is the path every
non-variant table takes, on every runMerge and every avro parquet read, and
it costs an Avro Field plus a defaultVal() lookup per field per level.
Build the list lazily, backfilling only once a replacement actually
appears. Measured on a 500-column nested schema: alignShreddedVariants
730us -> 335us per runMerge, create() 834us -> 365us per read.

Tests: the two-field shape engages and rebuilds, and a four-field struct
that merely carries typed_value stays untouched.
@voonhous

Copy link
Copy Markdown
Member Author

@wombatu-kun Can you please help to review this, just some cleaning up and hardening ontop of #19582.

@github-actions github-actions Bot added the size:L PR with lines of changes in (300, 1000] label Aug 13, 2026

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for the follow-up! This PR relaxes isShreddedVariantShape to accept the two-field {metadata, typed_value} shredded form (matching determineIfShredded), converts the record-rebuild loops in VariantSchemaUtils to lazy field copying, and adds reconstruction/merge test coverage. I traced the relaxed shape detection through isShreddedVariantTargetbuildRebuildertoShreddedReadSchema and into the real Spark4VariantShreddingProvider (where the no-value case yields variantIdx=-1, which Spark's ShreddingUtils.rebuild guards), and verified the lazy newFields/copyFieldsBefore rewrite is behavior-preserving (identity preserved on no-change, correct prefix copy on first change). No correctness issues found. A few style/readability suggestions in the inline comments. Please take a look, and this should be ready for a Hudi committer or PMC member to take it from here. One displaced Javadoc comment to fix; the logic and naming changes are clean.

cc @yihua

@codecov-commenter

codecov-commenter commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.87879% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.94%. Comparing base (e1dd86e) to head (f364d37).
⚠️ Report is 9 commits behind head on master.

Files with missing lines Patch % Lines
...rg/apache/hudi/common/avro/VariantSchemaUtils.java 87.87% 1 Missing and 3 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19620      +/-   ##
============================================
- Coverage     77.54%   76.94%   -0.60%     
+ Complexity    32928    32660     -268     
============================================
  Files          2524     2524              
  Lines        139513   139539      +26     
  Branches      16781    16818      +37     
============================================
- Hits         108183   107375     -808     
- Misses        23744    24507     +763     
- Partials       7586     7657      +71     
Components Coverage Δ
hudi-common 82.86% <87.87%> (-0.42%) ⬇️
hudi-client 81.51% <ø> (-1.20%) ⬇️
hudi-flink 85.76% <ø> (+0.02%) ⬆️
hudi-spark-datasource 70.25% <ø> (-0.57%) ⬇️
hudi-utilities 73.68% <ø> (+0.05%) ⬆️
hudi-cli 15.26% <ø> (-0.07%) ⬇️
hudi-hadoop 67.36% <ø> (-1.71%) ⬇️
hudi-sync 75.55% <ø> (-0.03%) ⬇️
hudi-io 79.56% <ø> (+0.18%) ⬆️
hudi-timeline-service 77.86% <ø> (-5.59%) ⬇️
hudi-cloud 64.06% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 50.88% <60.60%> (-0.01%) ⬇️
flink-integration-tests 49.16% <24.24%> (+<0.01%) ⬆️
hadoop-mr-java-client 43.80% <24.24%> (-0.07%) ⬇️
integration-tests 13.63% <24.24%> (-0.01%) ⬇️
spark-client-hadoop-common 50.54% <87.87%> (+0.02%) ⬆️
spark-java-tests 48.20% <60.60%> (-3.50%) ⬇️
spark-scala-tests 46.13% <60.60%> (+0.02%) ⬆️
utilities 36.60% <24.24%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...io/storage/hadoop/HoodieVariantReconstruction.java 92.55% <ø> (ø)
...rg/apache/hudi/common/avro/VariantSchemaUtils.java 88.13% <87.87%> (+15.05%) ⬆️

... and 152 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread hudi-common/src/main/java/org/apache/hudi/common/avro/VariantSchemaUtils.java Outdated
…er the value-less group

- The "forced shredding is top-level only in both write supports" note was wrong for the
  row path: processNestedDataType recurses into structs and generateShreddedSchema re-reads
  the DDL on every entry, so struct<v variant> plus the force-shredding property does shred
  at depth. Scoped the claim to HoodieAvroWriteSupport.applyForcedShreddingSchema in all
  three places that carried it.
- Added a value-less round-trip case in TestHoodieVariantReconstructionRoundTrip. The
  detection test in hudi-hadoop-common uses a stub provider that ignores the shredded
  schema, so nothing exercised Spark4VariantShreddingProvider.buildVariantSchema with the
  variantIdx = -1 that a {metadata, typed_value} group produces.
- Moved the stranded listDataParquetFiles javadoc back onto its method.
@hudi-bot

Copy link
Copy Markdown
Collaborator

CI report:

Bot commands @hudi-bot supports the following commands:
  • @hudi-bot run azure re-run the last Azure build

@hudi-agent hudi-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ 🤖 This review was generated by an AI agent and may contain mistakes. Please verify any suggestions before applying.

Thanks for the follow-up! This PR relaxes isShreddedVariantShape to accept the two-field {metadata, typed_value} shredded variant shape (value optional), converts the field-rebuild loops in stripRecordVariantShredding/swapShreddedVariantFields to lazy copies, and adds coverage for the value-less group and the disable-shredding strip path.

I traced the lazy field-building (output-identical to the prior eager version — same fresh-withSchema copies, correct size invariant, no off-by-one at i=0), the relaxed shape check across every field-count/value-presence case (the requested-side VARIANT anchor in isShreddedVariantTarget keeps false positives out), and the value-less rebuild end-to-end through buildVariantSchemaShreddingUtils.rebuild (variantIdx=-1 correctly skips the residual read, and detection stays consistent between toShreddedReadSchema and buildRebuilder). No issues flagged from this automated pass — a Hudi committer or PMC member can take it from here for a final review.

cc @yihua

* {@code Schema.Field} still bound to the source record throws, so any table with a shredded
* variant AND at least one other column failed here. #18938 fixed exactly that defect in the
* sibling HoodieVariantReconstruction and left this twin behind. Nested variants must be
* stripped too, since the row writer shreds at any depth.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generateEffectiveSchema is the AVRO write path, where applyForcedShreddingSchema walks top-level fields only, so the row writer is not what puts a nested shredded variant in front of it. Worth the same scoping the VariantSchemaUtils and HoodieVariantReconstruction comments now carry.

}

@Test
void ignoresFourFieldStructThatMerelyCarriesTypedValue(@TempDir Path tmp) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This four-field struct is killed by the field-count guard before the new no-value arm of isShreddedVariantShape, which nothing else reaches either - mutating its fieldCount == 2 to true leaves every test green. Worth a sibling case with {metadata, typed_value, extra}.

* exercise the shredded path can silently degenerate into the unshredded one, or the reverse,
* and the branch it was written for goes uncovered.
*/
private def assertVariantLayout(tablePath: String, shredded: Boolean, leg: String): Unit = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CDC test's layout block is this helper's body verbatim, with tablePath, shredded and leg all already in scope there. Worth folding that third site in too, since it is a drop-in call.

}

@Test
void createThenReconstructRebuildsAValueLessShreddedGroup(@TempDir Path tmp) throws Exception {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No mvn test -pl list in bot.yml or the Azure pipeline names hudi-spark-datasource/hudi-spark4-common, so this class is compiled but never executed - the variantIdx = -1 path it pins cannot go red in CI. Worth adding the module to the spark4.2 java-test lanes, or saying in the class javadoc that it is a local-only guard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L PR with lines of changes in (300, 1000]

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] CoW small-file merge silently nulls shredded VARIANT values via the avro reader path

5 participants