Skip to content

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

Merged
voonhous merged 4 commits into
apache:masterfrom
voonhous:variant-nested-shredded-internal-reads
Sep 1, 2026
Merged

fix(variant): project PushVariantIntoScan struct paths in the ...#19783
voonhous merged 4 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 Aug 28, 2026
@voonhous voonhous changed the title fix(variant): project PushVariantIntoScan struct paths in the file-group reader... 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.31%. Comparing base (6103a52) to head (42b6ff8).
⚠️ Report is 3 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.31%   +0.01%     
- Complexity    33861    33887      +26     
============================================
  Files          2541     2541              
  Lines        141676   141728      +52     
  Branches      17172    17182      +10     
============================================
+ Hits         110928   110990      +62     
+ Misses        23039    23030       -9     
+ Partials       7709     7708       -1     
Components Coverage Δ
hudi-common 83.66% <ø> (-0.01%) ⬇️
hudi-client 83.20% <66.66%> (+0.05%) ⬆️
hudi-flink 85.65% <ø> (-0.01%) ⬇️
hudi-spark-datasource 73.22% <82.05%> (+0.02%) ⬆️
hudi-utilities 74.53% <ø> (+<0.01%) ⬆️
hudi-cli 15.06% <ø> (ø)
hudi-hadoop 70.72% <ø> (ø)
hudi-sync 75.56% <ø> (ø)
hudi-io 79.88% <ø> (-0.12%) ⬇️
hudi-timeline-service 83.44% <ø> (ø)
hudi-cloud 65.81% <ø> (ø)
hudi-kafka-connect 53.20% <ø> (ø)
Flag Coverage Δ
common-and-other-modules 51.44% <41.66%> (-0.02%) ⬇️
flink-integration-tests 48.81% <ø> (-0.02%) ⬇️
hadoop-mr-java-client 44.07% <ø> (-0.03%) ⬇️
integration-tests 13.51% <0.00%> (-0.02%) ⬇️
spark-client-hadoop-common 50.55% <0.00%> (+0.04%) ⬆️
spark-java-tests 52.22% <78.43%> (-0.01%) ⬇️
spark-scala-tests 46.87% <74.50%> (+0.01%) ⬆️
utilities 36.55% <41.66%> (-0.02%) ⬇️

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 74.88% <60.00%> (+0.91%) ⬆️
...cution/datasources/SparkSchemaTransformUtils.scala 65.64% <33.33%> (-0.51%) ⬇️
...g/apache/spark/sql/adapter/BaseSpark4Adapter.scala 81.75% <81.57%> (-0.07%) ⬇️

... and 24 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
…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.
@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
// 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. Without a top-level variant the base-only split stays on
// requiredSchemaReaderSkipMerging, whose native VariantType request the Spark 4.1+ row reader

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.

Spark41LegacyHoodieParquetFileFormat extends ParquetFileFormat without overriding supportBatch, and VariantType is an AtomicType, so this split reads through the vectorized reader at stock settings - the variant veto lives only in HoodieFileGroupReaderBasedFileFormat. Name the parquet reader rather than the row reader here and in TestStreamingSource's scaladoc?

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.

Right: the legacy file format takes enableVectorizedReader straight from the inherited ParquetFileFormat.supportBatch, and VariantType is atomic, so that split reads through the vectorized reader unless the user turns it off. The comment here and the scaladoc in TestStreamingSource now say "parquet reader (the vectorized one at stock settings)" and name where the veto actually lives.

import org.apache.hudi.common.table.log.HoodieLogFormat
import org.apache.hudi.common.table.log.block.HoodieLogBlock.HoodieLogBlockType
import org.apache.hudi.common.testutils.HoodieTestUtils
import org.apache.hudi.common.util.StringUtils

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.

Moving listLogBlockTypes to VariantShreddingTestSupport left scala.collection.JavaConverters._ and getMetaClientAndFileSystemView imported here with no remaining use. Drop both.

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.

Dropped both.

// null - only its `inner` member is, and only for id 4.
val wholeStruct = spark.sql(s"select id, s from $tableName order by id").collect()
assert(wholeStruct.length == 5, s"[$leg] whole-struct read should return 5 rows")
assert(wholeStruct.forall(!_.isNullAt(1)), s"[$leg] whole-struct read nulled out s")

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 pins only the row count and that s is non-null, so s.inner coming back null or stale for every row would still pass. Check the nested payload here too, the way mergedJson already does for the rewritten reads?

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 whole-struct read now checks s.inner per row against mergedJson (VariantVal.toString is its JSON), null for id 4, alongside the non-null s check.

wombatu-kun pushed a commit to voonhous/hudi that referenced this pull request Sep 1, 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.
@wombatu-kun
wombatu-kun force-pushed the variant-nested-shredded-internal-reads branch 2 times, most recently from 2ea92de to 6b4ffe7 Compare September 1, 2026 02:49
Addresses the three follow-up threads on apache#19783.

- HoodieMergeOnReadRDDV2 and TestStreamingSource said the legacy
  skip-merging split is read by Spark's row reader; the legacy file
  format inherits ParquetFileFormat.supportBatch and VariantType is
  atomic, so at stock settings it is the vectorized reader. The
  variant veto lives in HoodieFileGroupReaderBasedFileFormat only.
- The whole-struct read in the nested MOR legs checks the s.inner
  payload per row, not just the row count and a non-null s.
- TestVariantDataType drops the JavaConverters and
  getMetaClientAndFileSystemView imports left behind when
  listLogBlockTypes moved to VariantShreddingTestSupport.
@hudi-bot

hudi-bot commented Sep 1, 2026

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
@voonhous
voonhous merged commit c59987a into apache:master Sep 1, 2026
23 checks passed
@voonhous
voonhous deleted the variant-nested-shredded-internal-reads branch September 1, 2026 11:24
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

4 participants