Skip to content

fix(variant): project PushVariantIntoScan struct paths in the ... - #19783

Open
voonhous wants to merge 3 commits into
apache:masterfrom
voonhous:variant-nested-shredded-internal-reads
Open

fix(variant): project PushVariantIntoScan struct paths in the ...#19783
voonhous wants to merge 3 commits into
apache:masterfrom
voonhous:variant-nested-shredded-internal-reads

Conversation

@voonhous

@voonhous voonhous commented Aug 28, 2026

Copy link
Copy Markdown
Member

...file-group reader and pin nested-shredded internal reads

TL;DR

  • The code change is a read-path fix. It is not specific to compaction/clustering, and not specific to shredding.
  • With Spark's default spark.sql.variant.pushVariantIntoScan=true, a plain query touching a struct-nested variant (cast(s.inner as string), variant_get(s.inner, ...)) on a MOR table with log files crashed the JVM or returned nulls.
  • Cause: the file-group reader applied Spark's pushed-down projection at the top level only. Unshredded data was affected too; COW was fine.
  • Everything about compaction/clustering/CDC in this PR is verification: those internal reads of nested-shredded files were already correct. Comments corrected, tests added, no code change there.

Describe the issue this Pull Request addresses

Closes #19775. Part of #18937. Stacked on #19777: its squashed commit comes first, review the rest.

The bug:

  • Spark 4.1+ rewrites a variant reached through a struct path into a projection struct nested in the scan schema, e.g. s: struct<inner: struct<"0": string>>.
  • SparkFileFormatInternalRowReaderContext overlaid and projected that struct for top-level fields only.
  • On a MOR table with log files the merged row therefore still held a raw VariantVal at s.inner while the plan read it as the projected struct: SIGBUS, InternalError, OOM, or silent nulls. Any log format, any record type.
  • Repro: a MOR table (id int, s struct<inner: variant>, ts long) with hoodie.index.type=INMEMORY, one insert, then select id, cast(s.inner as string) from t.

The comments:

  • Four comment blocks deferred the nested case, claiming a native VariantType request clips a shredded file to {metadata, value}.
  • It does not. The Spark 4.1.1 row reader reconstructs a shredded variant at any depth.

Summary and Changelog

Struct-nested variants now read correctly through the Spark file-group reader on MOR tables with logs.

  • SparkFileFormatInternalRowReaderContext: the projection overlay and its detection recurse into struct members, mirroring Spark's VariantInRelation.rewriteType. Struct paths only; arrays and maps stay native.
  • BaseSpark4Adapter: buildVariantProjector hoisted from the identical 4.1/4.2 adapter copies and made recursive. A null variant in an avro log record now projects to a NULL struct instead of a struct of nulls: PushVariantIntoScan rewrites IsNull(v) / IsNotNull(v) onto that struct, so where s.inner is null lost the row before.
  • SparkSchemaTransformUtils.addMissingFields: a projection struct requested over a file VariantType stays the reader type when a sibling member of the same struct has an implicit type change (a file with s.n int under a table widened to long); it used to be folded back to VariantType, and the type-change Cast then cast the variant value to the projected struct.
  • SparkAdapter.containsVariantProjection: one definition for the reader context's overlay and the adapter's projector.
  • The four deferral comments now state the verified mechanism. Every existing top-level leg, including the [BUG] COW clustering silently nulls shredded VARIANT values for rewritten rows #19556 repro at its own fix commit, passes with the full-variant rewrite disabled; only the schema-on-read fail-fast message depends on that shape. The rewrite stays top-level as an explicit contract. Removing it is a separate decision.

Tests, all over tables whose only variant is the nested one:

  • TestVariantShreddingMixedLayouts, section F:
    • MOR merge, read-optimized, compaction under the forced DDL and unshredded, clustering on both writer paths.
    • CDC images in both supplemental logging modes.
    • Nested variant_get and cast on COW, and on MOR over native parquet logs and table-version-9 avro blocks, with pushVariantIntoScan on and off. JVM abort before the fix.
    • An array<variant> element shredded through a declared hoodie.write.schema, across reads and compaction.
    • The MOR legs null one variant through the log and check is null / is not null; each pushVariantIntoScan arm pins whether the scan's required schema carries the projection struct, so the two arms cannot silently collapse into one.
    • Implicit widening of a sibling (s.n int to long) beside a projected nested variant.
  • TestBaseSpark4AdapterVariantMethods: the recursive projector as a unit, a null variant inside a live struct, and the implicit-change reconciliation keeping the projection struct.
  • TestStreamingSource: the streaming legacy-RDD leg twice, with a top-level variant beside the nested one (re-routed to the file-group reader) and nested-only (base-only split served by Spark's own row reader over the shredded group).

Impact

Reads of struct-nested variants on MOR tables with log files no longer crash or return nulls; is null over avro log records and reads beside an implicitly widened sibling are correct. No config or API changes.

Risk Level

low: the projector change is exercised by the new MOR legs on both log formats and both record types; the existing top-level suites are unchanged in behaviour.

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

@voonhous voonhous changed the title fix(variant): project PushVariantIntoScan struct paths in the file-group reader and pin nested-shredded internal reads fix(variant): project PushVariantIntoScan struct paths in the file-group reader... Aug 28, 2026
@voonhous voonhous changed the title fix(variant): project PushVariantIntoScan struct paths in the file-group reader... fix(variant): project PushVariantIntoScan struct paths in the ... Aug 28, 2026
@github-actions github-actions Bot added the size:XL PR with lines of changes > 1000 label Aug 28, 2026
@codecov-commenter

codecov-commenter commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.43137% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.29%. Comparing base (6103a52) to head (6b4ffe7).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
...g/apache/spark/sql/adapter/BaseSpark4Adapter.scala 81.57% 4 Missing and 3 partials ⚠️
...hudi/SparkFileFormatInternalRowReaderContext.scala 60.00% 0 Missing and 2 partials ⚠️
...cution/datasources/SparkSchemaTransformUtils.scala 33.33% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #19783      +/-   ##
============================================
- Coverage     78.29%   78.29%   -0.01%     
- Complexity    33861    33870       +9     
============================================
  Files          2541     2541              
  Lines        141676   141696      +20     
  Branches      17172    17177       +5     
============================================
+ Hits         110928   110943      +15     
- Misses        23039    23047       +8     
+ Partials       7709     7706       -3     
Components Coverage Δ
hudi-common 83.64% <ø> (-0.03%) ⬇️
hudi-client 83.16% <66.66%> (+<0.01%) ⬆️
hudi-flink 85.66% <ø> (+<0.01%) ⬆️
hudi-spark-datasource 73.21% <82.05%> (+0.02%) ⬆️
hudi-utilities 74.55% <ø> (+0.03%) ⬆️
hudi-cli 15.06% <ø> (ø)
hudi-hadoop 70.72% <ø> (ø)
hudi-sync 75.54% <ø> (-0.03%) ⬇️
hudi-io 79.90% <ø> (-0.10%) ⬇️
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 65.81% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 51.45% <41.66%> (-0.01%) ⬇️
flink-integration-tests 48.83% <ø> (+<0.01%) ⬆️
hadoop-mr-java-client 44.11% <ø> (+<0.01%) ⬆️
integration-tests 13.51% <0.00%> (-0.01%) ⬇️
spark-client-hadoop-common 50.50% <0.00%> (+<0.01%) ⬆️
spark-java-tests 52.23% <78.43%> (+0.01%) ⬆️
spark-scala-tests 46.90% <74.50%> (+0.03%) ⬆️
utilities 36.56% <41.66%> (-0.01%) ⬇️

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

Files with missing lines Coverage Δ
...scala/org/apache/spark/sql/hudi/SparkAdapter.scala 63.63% <100.00%> (+8.08%) ⬆️
...scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala 79.53% <ø> (ø)
...ala/org/apache/hudi/cdc/CDCFileGroupIterator.scala 79.07% <ø> (ø)
...org/apache/spark/sql/adapter/Spark4_2Adapter.scala 67.69% <100.00%> (-0.09%) ⬇️
...hudi/SparkFileFormatInternalRowReaderContext.scala 73.05% <60.00%> (-0.92%) ⬇️
...cution/datasources/SparkSchemaTransformUtils.scala 65.64% <33.33%> (-0.51%) ⬇️
...g/apache/spark/sql/adapter/BaseSpark4Adapter.scala 81.75% <81.57%> (-0.07%) ⬇️

... and 16 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.

@voonhous
voonhous force-pushed the variant-nested-shredded-internal-reads branch from e268d40 to 115b202 Compare August 28, 2026 11:28
@voonhous
voonhous requested a review from wombatu-kun August 28, 2026 11:53
@voonhous
voonhous force-pushed the variant-nested-shredded-internal-reads branch from 115b202 to a51b75e Compare August 28, 2026 12:43
(f.dataType, SparkFileFormatInternalRowReaderContext.findFieldByName(source, f.name).map(_.dataType)) match {
case (_, Some(projStruct: StructType)) if sparkAdapter.isVariantProjectionStruct(projStruct) =>
f.copy(dataType = projStruct)
case (targetStruct: StructType, Some(sourceStruct: StructType)) =>

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.

SparkSchemaTransformUtils.addMissingFields has no variant-projection case, so once any sibling member of the enclosing struct triggers an implicit type change the nested projection installed here is replaced by the file's VariantType in the read schema. Should it get the same special case isDataTypeEqualForPhysicalSchema already has, or is implicit evolution alongside a nested variant out of scope for this PR?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

In scope: addMissingFields now has the arm for the pair isDataTypeEqual already declares equal. A projection struct requested over a file VariantType stays the reader type instead of being folded back to VariantType -- which parquet then decoded as a raw variant that the type-change Cast cast to the projected struct, i.e. nulls for that member. Pinned in TestBaseSpark4AdapterVariantMethods.testImplicitSchemaChangeKeepsNestedVariantProjection (file s.n int, table s.n long, s.inner projected: the reconciled reader type keeps the projection struct) and functionally by the new "Implicit widening of a sibling keeps the nested variant projection" leg in TestVariantShreddingMixedLayouts.

// below, whose reader context requests the full-variant projection shape for parquet base
// files (#19578), so a SHREDDED base file is read on this legacy path through the same
// contract as everywhere else. The skip-merging reader's native VariantType request is
// reconstructed by the Spark 4.1+ row reader as well (pinned by TestStreamingSource), so this

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.

TestStreamingSource's table also carries a top-level v, so shouldRerouteVariantSplit is true there and the base-only split never reaches requiredSchemaReaderSkipMerging - that leg is not what the test pins. Drop the reference here and in the matching "Not swept here" note in TestVariantShreddingMixedLayouts, or add a nested-only leg that actually takes the branch?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added the nested-only leg rather than dropping the claim. TestStreamingSource now runs the legacy shredded-variant stream twice, with and without the top-level v. Without it shouldRerouteVariantSplit is false, so the base-only split goes through requiredSchemaReaderSkipMerging and Spark's own row reader rebuilds s.inner from the shredded group; the footer pin asserts s.inner is shredded and that the file carries no v. This comment and the "Not swept here" note in TestVariantShreddingMixedLayouts now name that leg.

// Pin that the compacted base file is shredded at both depths: without it the streams below
// would pass just the same over an unshredded base and pin nothing about shredded reads.
val conf = spark.sessionState.newHadoopConf()
val baseFiles = new Path(tablePath).getFileSystem(conf).listStatus(new Path(tablePath))

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.

A native parquet log file is named <fileId>_<token>_<instant>_<v>.log.parquet with no dot prefix, so this listing also picks up the two deltacommit logs and "expected a compacted base file" is satisfied even if inline compaction never ran. Filter with FSUtils.isBaseFile the way assertNestedBaseLayout does.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done: the listing filters with FSUtils.isBaseFile, and the message says log files are excluded.

| 1000L as ts from range($lo, $hi, 1, 1)""".stripMargin

Seq("true", "false").foreach { pushIntoScan =>
withSQLConf("spark.sql.variant.pushVariantIntoScan" -> pushIntoScan) {

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.

Nothing in these legs asserts that PushVariantIntoScan actually fired, and both arms expect the same rows, so if the rule ever stops matching HoodieFileGroupReaderBasedFileFormat the projected arm silently becomes a duplicate of the other one. Worth pinning the plan in the true arm the way assertLegacyRddPlan pins its own path in TestStreamingSource - follow-up, not a blocker.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added variantProjectionPushedIntoScan(sql) to VariantShreddingTestSupport: it collects the FileSourceScanExec off sparkPlan (AQE hides it on executedPlan) and asks whether its requiredSchema carries a projection struct, through the new SparkAdapter.containsVariantProjection. The COW loop and both MOR legs assert it equals their pushVariantIntoScan arm, and the new widening leg asserts it stays pushed.

VariantGet(ref, pathLit, child.dataType, vm.failOnError, Option(vm.timeZoneId))
Seq(Literal(UTF8String.fromString(child.name), StringType), variantGet)
}
CreateNamedStruct(childExprs)

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.

A null variant in an avro log record is projected into a non-null struct of nulls here, while the parquet paths leave the field null - and PushVariantIntoScan rewrites IsNull(v) / IsNotNull(v) directly onto that struct. Wrap this in the same If(IsNull(...)) guard the struct rebuild below already carries?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed: the projection-struct arm carries the same If(IsNull(ref), null, ...) guard as the struct rebuild below it. Pinned twice: the projector unit test feeds a null variant inside a live struct and expects a null struct with the sibling intact, and the nested MOR legs now null id 4's variant through the log and check where s.inner is null / is not null; the avro-block leg with the rule on returned no row for is null before the fix.

}

/** True when `dataType` is a variant projection struct or holds one below a struct path. */
private def containsVariantProjection(dataType: DataType): Boolean = dataType match {

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.

containsVariantProjection is now defined identically here and in SparkFileFormatInternalRowReaderContext. SparkAdapter already hosts isVariantProjectionStruct, so a default method there would give both call sites one definition.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Moved to SparkAdapter as a concrete method over isVariantProjectionStruct; both private copies are gone.

…oup reader and pin nested-shredded internal reads

Spark 4.1+'s PushVariantIntoScan rewrites a variant reached through a struct
path (cast(s.inner as string), variant_get(s.inner, ...)) into a projection
struct nested inside the scan's required schema. The Spark file-group-reader
context applied that projection to top-level fields only, so on a MOR table
with logs the merged row still held a raw VariantVal at s.inner while the plan
read that memory as the projected struct: SIGBUS / InternalError / OOM or a
silent null, on any log format and record type, shredded or not. COW was
unaffected because the whole catalyst schema goes to Spark's parquet reader,
which handles a projection struct at any depth.

- SparkFileFormatInternalRowReaderContext: overlayVariantProjections and
  shouldProjectVariants recurse into struct members, mirroring
  VariantInRelation.rewriteType (struct paths only; arrays and maps stay
  native).
- BaseSpark4Adapter: buildVariantProjector implementation hoisted out of the
  identical Spark4_1Adapter / Spark4_2Adapter copies and made recursive: a
  projection struct below a struct path is rebuilt with VariantGet children,
  enclosing structs are recreated null-preservingly, untouched subtrees are
  passed through by reference.
- The four comment blocks that deferred the nested case (reader context,
  SparkAdapter.buildFullVariantReadSchema, HoodieMergeOnReadRDDV2,
  CDCFileGroupIterator) described the wrong mechanism: the Spark 4.1.1 row
  reader reconstructs a native VariantType request at any depth, so the
  full-variant projection shape is a contract, not a workaround for a reader
  clip, and stays top-level. Verified with the rewrite disabled (every existing
  top-level leg green, including the apache#19556 repro at its own fix commit); the
  only thing the shape still carries is the schema-on-read fail-fast wording.

Tests (TestVariantShreddingMixedLayouts section F, nested-only tables through
a new withNestedOnlyVariantTable scaffold; TestBaseSpark4AdapterVariantMethods;
TestStreamingSource): MOR merge, read-optimized, compaction under the forced
DDL and unshredded, clustering on both writer paths; CDC images through
hudi_table_changes in both logging modes; variant_get / cast on the nested
path over typed and residual files with pushVariantIntoScan on and off, on COW
and on MOR over native parquet logs and table-version-9 avro blocks (red
before the fix: JVM abort); the recursive projector as a unit; the streaming
legacy RDD leg extended with a nested-shredded column and a footer pin.

Closes apache#19775. Part of apache#18937. Stacked on apache#19777.
… schema across snapshot reads and compaction

No DDL forces a variant that is directly an array element and Spark's
PushVariantIntoScan rewrites struct paths only, so array<variant> is the
shape every reader handles natively, shredded or not. The only Hudi write
that produces a shredded element is a write schema declaring typed_value
there (hoodie.write.schema, honoured by the row write support; the AVRO
record-type insert cannot take an override of a different shape). Pins the
footer, snapshot reads with the vectorized reader and pushVariantIntoScan
swept, variant_get on an element, a log update, read-optimized, compaction
under the declared schema (element re-shredded) and without it (element
unshredded).

Part of apache#19775.
@voonhous
voonhous force-pushed the variant-nested-shredded-internal-reads branch from a51b75e to 4e1524d Compare August 31, 2026 08:48
@github-actions github-actions Bot added size:L PR with lines of changes in (300, 1000] and removed size:XL PR with lines of changes > 1000 labels Aug 31, 2026
Addresses the six review threads on apache#19783.

- BaseSpark4Adapter: a null variant in an avro log record projects to
  a NULL struct, not a struct of nulls; PushVariantIntoScan rewrites
  IsNull/IsNotNull onto the projection struct, so `s.inner is null`
  lost the row. Same If(IsNull) guard the struct rebuild carries.
- SparkSchemaTransformUtils.addMissingFields: a projection struct over
  a file VariantType stays the reader type when a sibling member has
  an implicit type change; it was folded back to VariantType and the
  type-change Cast then cast the variant value to the struct.
- SparkAdapter.containsVariantProjection is the one definition; the
  private copies in the adapter and the reader context are gone.
- HoodieMergeOnReadRDDV2: the comment claimed TestStreamingSource pins
  the skip-merging reader over a nested-shredded base; that test's
  table has a top-level variant, so its splits are re-routed. The leg
  now runs twice, with and without the top-level variant, and the
  nested-only leg is what the comment names.
- TestStreamingSource lists base files through FSUtils.isBaseFile
  (native parquet logs matched the ".parquet" suffix).
- TestVariantShreddingMixedLayouts: each pushVariantIntoScan arm pins
  whether the scan's required schema carries the projection struct;
  the MOR legs null one variant through the log; a new leg widens a
  sibling (s.n int -> long) through the DataFrame API and reads the
  int file with the projection pushed. The catalog view of the SQL
  table keeps reporting the old type, so that leg reads through a
  path-based view.
- Post-rebase compile fix: the array-element write schema uses the
  4-arg createVariantShreddedObject.
@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

@github-actions github-actions Bot added size:XL PR with lines of changes > 1000 and removed size:L PR with lines of changes in (300, 1000] labels Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL PR with lines of changes > 1000

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pin nested-shredded variant base files through the Spark-native internal read path; extend the full-variant rewrite to struct paths if red

4 participants