This is an automated email from the ASF dual-hosted git repository.
voonhous pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 3ba31dd37fff fix(spark): read shredded variants through internal
write-side parquet reads (#19558)
3ba31dd37fff is described below
commit 3ba31dd37fffa5521c8100d5e8f4617af5596048
Author: voonhous <[email protected]>
AuthorDate: Tue Aug 11 19:05:36 2026 +0800
fix(spark): read shredded variants through internal write-side parquet
reads (#19558)
* fix(spark): read shredded variants through internal write-side parquet
reads
Clustering, compaction base-file reads, and Spark-merger upsert merges read
parquet base files through SparkFileFormatInternalRowReaderContext without a
catalyst plan, so nothing rewrote VariantType columns the way
PushVariantIntoScan does for user queries. Against a shredded file the
reader
clipped the variant group to {metadata, value} and read value=null, and the
rewrite persisted the nulls, silently losing the variant data of every
carried-over row.
- Add SparkAdapter.buildFullVariantReadSchema, rewriting top-level
VariantType
fields into the full-variant projection struct (single child "0" at path
$)
mirroring RequestedVariantField.fullVariant; implemented for Spark
4.1/4.2,
None elsewhere (no shredded read support, no shredded writes either).
- In SparkFileFormatInternalRowReaderContext, apply the rewrite to internal
parquet base-file scans and restore native VariantType by projecting
child 0
after the read; composes with the VECTOR binary post-processing and is
inert
for Lance/ORC/log files and catalyst-provided schemas.
- Add shredded + unshredded COW clustering round-trips (small.file.limit=0
keeps the CoW small-file merge, which has a separate unfixed avro-path
leg,
out of the repro) and bulk_insert row-writer round-trips for both layouts.
Part of #19556; the avro-path leg (HoodieVariantReconstruction not engaging
on real files) is tracked on the issue.
* review(19558): address round-1 comments
- Move the buildFullVariantReadSchema implementation into BaseSpark4Adapter;
Spark 4.1/4.2 keep one-line opt-in overrides since Spark 4.0 must stay on
None (its reader cannot reconstruct shredded values).
- Reword the reader-context comment: Spark 4.0 does write shredded files
(the
write-side adapter methods have no version gate); only its read side is
missing. Note that MOR incremental, streaming and CDC reads build this
context without sparkRequiredSchema and take (and need) the same rewrite,
and that the nested-variant leg is deferred until a production write path
can produce nested shredded files.
- Extend the MOR compaction test with a second round that reads the shredded
base file produced by the first compaction through the internal reader;
rows 3 and 4 exist only in that base file.
- Assert typed_value in the pre-clustering base file so the shredded
clustering test cannot silently degrade into its unshredded twin.
* review(19558): address round-2 comments
- Add a batch incremental round trip over the shredded MOR table. On current
table versions it scans through the file-group-reader file format with a
catalyst schema (the overlay leg); the no-catalyst-schema legs (streaming
source, CDC) are tracked in #19578.
- Pin the unshredded clustering twin's layout: its pre-clustering base file
must not carry typed_value, so the twin cannot silently become a copy of
the shredded test.
* review(19558): narrow the no-catalyst-schema query legs to CDC
Under default configs only CDCFileGroupIterator builds this context without
a
catalyst schema; the streaming source and MergeOnRead relations reach their
RDD paths only with hoodie.file.group.reader.enabled=false, and batch
incremental always scans through the file format. Reword the reader-context
comment and the test note accordingly (#19578 description corrected too).
---
.../SparkFileFormatInternalRowReaderContext.scala | 79 +++++-
.../org/apache/spark/sql/hudi/SparkAdapter.scala | 17 ++
.../sql/hudi/dml/schema/TestVariantDataType.scala | 272 ++++++++++++++++++++-
.../spark/sql/adapter/BaseSpark4Adapter.scala | 25 +-
.../apache/spark/sql/adapter/Spark4_1Adapter.scala | 5 +
.../apache/spark/sql/adapter/Spark4_2Adapter.scala | 5 +
6 files changed, 396 insertions(+), 7 deletions(-)
diff --git
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
index 7671c64bc219..5752bcfea918 100644
---
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
+++
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/hudi/SparkFileFormatInternalRowReaderContext.scala
@@ -37,7 +37,7 @@ import org.apache.hudi.util.CloseableInternalRowIterator
import
org.apache.parquet.avro.HoodieAvroParquetSchemaConverter.getAvroSchemaConverter
import org.apache.spark.sql.HoodieInternalRowUtils
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.{JoinedRow, UnsafeProjection}
+import org.apache.spark.sql.catalyst.expressions.{BoundReference, Expression,
GetStructField, JoinedRow, UnsafeProjection}
import org.apache.spark.sql.execution.datasources.{PartitionedFile,
SparkColumnarFileReader}
import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
import org.apache.spark.sql.internal.SQLConf
@@ -159,7 +159,40 @@ class
SparkFileFormatInternalRowReaderContext(baseFileReader: SparkColumnarFileR
structType
}
- val (readSchema, readFilters) =
getSchemaAndFiltersForRead(parquetReadStructType, hasRowIndexField)
+ // Internal reads have no catalyst plan, so nothing rewrites VariantType
fields the way
+ // PushVariantIntoScan does for user queries. Requesting native
VariantType against a
+ // SHREDDED parquet base file clips the file group to {metadata, value}
and reads
+ // value=null; write-side callers (compaction, clustering, merge) would
then persist the
+ // nulls, silently losing the variant data (#19556). Query paths that
build this context
+ // without sparkRequiredSchema hit the same null reads and take the same
rewrite: CDC
+ // (CDCFileGroupIterator) under default configs, plus the legacy RDD paths
(streaming
+ // source, MergeOnRead relations) only when
hoodie.file.group.reader.enabled=false;
+ // batch incremental always scans through the file format with a catalyst
schema.
+ // Request the full-variant projection shape
+ // instead and restore native VariantType after the scan. User-facing
reads pass
+ // sparkRequiredSchema and are overlaid above. Only top-level variant
fields are
+ // rewritten: no production write path shreds a nested variant today (a
nested shredded
+ // write schema needs the test-only force config until shredding-schema
inference
+ // lands), so the nested leg is deferred until such files can exist. Spark
< 4.1 cannot
+ // reconstruct shredded values on read (SPARK-54410) and the adapter
returns None;
+ // Spark 4.0 does write shredded files, so its pre-existing read-side gap
stays as is.
+ val isParquetBaseFile = !isInlineLog && !FSUtils.isLogFile(filePath) &&
+ HoodieFileFormat.fromFileExtension(filePath.getFileExtension) ==
HoodieFileFormat.PARQUET
+ val (readStructTypeForScan, variantOrdinals) =
+ if (sparkRequiredSchema.isEmpty && isParquetBaseFile) {
+ sparkAdapter.buildFullVariantReadSchema(parquetReadStructType) match {
+ case Some(rewritten) =>
+ val ordinals = rewritten.fields.indices
+ .filter(i => rewritten.fields(i).dataType !=
parquetReadStructType.fields(i).dataType)
+ .toSet
+ (rewritten, ordinals)
+ case None => (parquetReadStructType, Set.empty[Int])
+ }
+ } else {
+ (parquetReadStructType, Set.empty[Int])
+ }
+
+ val (readSchema, readFilters) =
getSchemaAndFiltersForRead(readStructTypeForScan, hasRowIndexField)
if (FSUtils.isLogFile(filePath)) {
// NOTE: now only primary key based filtering is supported for log files
// Position-based merging pairs log records with the RECORD_POSITIONS
bitmap by index (see
@@ -205,11 +238,21 @@ class
SparkFileFormatInternalRowReaderContext(baseFileReader: SparkColumnarFileR
readSchema, StructType(Seq.empty),
getSchemaHandler.getInternalSchemaOpt,
readFilters,
storage.getConf.asInstanceOf[StorageConfiguration[Configuration]],
tableSchemaOpt))
- // Post-process: convert binary VECTOR columns back to typed arrays
+ // Post-process: restore native VariantType from the full-variant
projection shape
+ // (child 0 of each rewritten field), then convert binary VECTOR columns
back to arrays.
+ val (variantRestoredIterator, variantRestoredSchema) = if
(variantOrdinals.nonEmpty) {
+ val restoredSchema = StructType(readSchema.fields.zipWithIndex.map {
case (f, i) =>
+ if (variantOrdinals.contains(i)) f.copy(dataType =
structType.fields(i).dataType) else f
+ })
+
(SparkFileFormatInternalRowReaderContext.wrapWithVariantRestore(rawIterator,
readSchema, variantOrdinals),
+ restoredSchema)
+ } else {
+ (rawIterator, readSchema)
+ }
if (vectorColumnInfo.nonEmpty) {
-
SparkFileFormatInternalRowReaderContext.wrapWithVectorConversion(rawIterator,
vectorColumnInfo, readSchema)
+
SparkFileFormatInternalRowReaderContext.wrapWithVectorConversion(variantRestoredIterator,
vectorColumnInfo, variantRestoredSchema)
} else {
- rawIterator
+ variantRestoredIterator
}
}
}
@@ -435,6 +478,32 @@ object SparkFileFormatInternalRowReaderContext {
VectorConversionUtils.replaceVectorColumnsWithBinary(structType, javaMap)
}
+ /**
+ * Restores native VariantType columns from the full-variant projection
shape requested for
+ * internal reads of parquet base files (see
SparkAdapter.buildFullVariantReadSchema): each
+ * rewritten column is a struct with a single child "0" holding the
reconstructed variant,
+ * so restoring is a projection of that child.
+ */
+ private[hudi] def wrapWithVariantRestore(
+ iterator: ClosableIterator[InternalRow],
+ readSchema: StructType,
+ variantOrdinals: Set[Int]): ClosableIterator[InternalRow] = {
+ val exprs: Seq[Expression] = readSchema.fields.zipWithIndex.map { case
(field, i) =>
+ val ref = BoundReference(i, field.dataType, field.nullable)
+ if (variantOrdinals.contains(i)) {
+ GetStructField(ref, 0, Some("0"))
+ } else {
+ ref: Expression
+ }
+ }.toSeq
+ val projection = UnsafeProjection.create(exprs)
+ new ClosableIterator[InternalRow] {
+ override def hasNext: Boolean = iterator.hasNext
+ override def next(): InternalRow = projection(iterator.next())
+ override def close(): Unit = iterator.close()
+ }
+ }
+
/**
* Wraps an iterator to convert binary VECTOR columns back to typed arrays.
* Unpacks bytes from FIXED_LEN_BYTE_ARRAY into GenericArrayData using the
canonical vector byte order.
diff --git
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
index 7cc32490fbfb..f0b6292af455 100644
---
a/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
+++
b/hudi-client/hudi-spark-client/src/main/scala/org/apache/spark/sql/hudi/SparkAdapter.scala
@@ -518,6 +518,23 @@ trait SparkAdapter extends Serializable {
def buildVariantProjector(sparkDataSchema: StructType,
sparkRequiredSchema: StructType):
Option[InternalRow => InternalRow] = None
+ /**
+ * Rewrites each top-level VariantType field of `schema` into the
full-variant projection
+ * struct that PushVariantIntoScan would request for whole-variant access: a
single child
+ * field "0" of VariantType carrying `VariantMetadata` for path "$".
Requesting that shape
+ * makes the parquet reader reconstruct shredded variants by field name;
requesting native
+ * VariantType instead clips a shredded file group down to {metadata, value}
and reads
+ * value=null (#19556).
+ *
+ * Used by internal (non-catalyst) reads of parquet base files, which have no
+ * PushVariantIntoScan to do this for them. The caller restores the native
VariantType
+ * shape by projecting child 0 of each rewritten field.
+ *
+ * Returns None when the schema has no top-level VariantType field or the
Spark version has
+ * no shredded-read support (Spark 3.x / 4.0).
+ */
+ def buildFullVariantReadSchema(schema: StructType): Option[StructType] = None
+
/**
* Generates a shredded Variant schema and marks it with write shredding
metadata.
*
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
index a99393269cd1..8b270a097a3e 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantDataType.scala
@@ -19,7 +19,7 @@
package org.apache.spark.sql.hudi.dml.schema
-import org.apache.hudi.HoodieSparkUtils
+import org.apache.hudi.{DataSourceReadOptions, HoodieSparkUtils}
import org.apache.hudi.common.schema.HoodieSchema
import org.apache.hudi.common.schema.internal.HoodieSchemaException
import org.apache.hudi.common.testutils.HoodieTestUtils
@@ -250,9 +250,279 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
metaClient.reloadActiveTimeline()
assert(metaClient.getActiveTimeline.getCleanerTimeline.countInstants() >
0,
"Expected at least one .clean instant on the timeline after
compaction")
+
+ // Round 2: the compaction above compacted a log-only slice, so it never
read a
+ // parquet base file. Pin that the compacted base file is shredded, then
drive a
+ // second compaction that reads it through the internal reader
+ // (SparkFileFormatInternalRowReaderContext) and must carry rows 3 and
4, which
+ // exist only in that base file, forward (#19556).
+ val compactedFiles = listDataParquetFiles(tablePath)
+ assert(compactedFiles.nonEmpty, "Should have a compacted base parquet
file")
+ compactedFiles.foreach { filePath =>
+ val parquetSchema = readParquetSchema(filePath)
+ val variantGroup = getFieldAsGroup(parquetSchema, "v")
+ assert(variantGroup.containsField("typed_value"),
+ s"Compacted base file should carry typed_value.
Schema:\n$variantGroup")
+ }
+
+ // The v2-merged deltacommit above was the first after the compaction;
four more
+ // reach max.delta.commits = 5 and trip the second compaction inline.
+ spark.sql(s"""update $tableName set v = parse_json('{"key":"v1-r2"}'),
ts = 1003 where id = 1""")
+ spark.sql(s"""update $tableName set v = parse_json('{"key":"v2-r2"}'),
ts = 1004 where id = 2""")
+ spark.sql(s"""update $tableName set v = parse_json('{"key":"v1-r3"}'),
ts = 1005 where id = 1""")
+ spark.sql(s"""update $tableName set v = parse_json('{"key":"v2-r3"}'),
ts = 1006 where id = 2""")
+
+ metaClient.reloadActiveTimeline()
+
assertResult(2)(metaClient.getActiveTimeline.getCommitTimeline.filterCompletedInstants.countInstants)
+
+ checkAnswer(s"select id, cast(v as string), ts from $tableName order by
id")(
+ Seq(1, "{\"key\":\"v1-r3\"}", 1005),
+ Seq(2, "{\"key\":\"v2-r3\"}", 1006),
+ Seq(3, "{\"key\":\"value3\"}", 1000),
+ Seq(4, "{\"key\":\"value4\"}", 1000)
+ )
+
+ // Incremental round trip over the shredded table: batch incremental
reads the
+ // shredded base file through the file-group-reader file format with a
catalyst
+ // schema, a stack nothing else in this suite pins for variant columns.
The
+ // no-catalyst-schema legs (CDC, and streaming with
+ // hoodie.file.group.reader.enabled=false) are tracked in #19578.
+ val incRows = spark.read.format("hudi")
+ .option(DataSourceReadOptions.QUERY_TYPE.key,
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
+ .option(DataSourceReadOptions.START_COMMIT.key, "000")
+ .load(tablePath)
+ .selectExpr("id", "cast(v as string) as v", "ts")
+ .orderBy("id")
+ .collect()
+ assertResult(4)(incRows.length)
+ assertResult("{\"key\":\"v1-r3\"}")(incRows(0).getString(1))
+ assertResult("{\"key\":\"v2-r3\"}")(incRows(1).getString(1))
+ assertResult("{\"key\":\"value3\"}")(incRows(2).getString(1))
+ assertResult("{\"key\":\"value4\"}")(incRows(3).getString(1))
})
}
+ test("Test COW clustering preserves VARIANT values") {
+ // Same Spark 4.1 gate as the compaction test above: clustering reads the
shredded
+ // base files back through the native reader, which rejects the 3-field
shredded
+ // layout before SPARK-54410 (Spark 4.1+).
+ assume(HoodieSparkUtils.gteqSpark4_1, "Shredded variant base-file read
requires Spark 4.1 or higher")
+
+ withRecordType()(withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = tmp.getCanonicalPath
+ // Clustering rewrites ALL rows of the clustered file groups through the
internal
+ // write-side reader context (SparkReaderContextFactory ->
+ // SparkFileFormatInternalRowReaderContext), the stack whose blob
handling silently
+ // lost bytes in #19232. Nothing pinned its VARIANT behavior: this is
the first
+ // clustering coverage for the type. Shredding is forced so the rewrite
reads and
+ // rewrites the shredded layout, the default in production.
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | v variant,
+ | ts long
+ |) using hudi
+ | location '$tablePath'
+ | tblproperties (
+ | primaryKey = 'id',
+ | type = 'cow',
+ | preCombineField = 'ts',
+ | hoodie.parquet.variant.write.shredding.enabled = 'true',
+ | hoodie.parquet.variant.force.shredding.schema.for.test = 'key
string',
+ | hoodie.index.type = 'INMEMORY',
+ | hoodie.parquet.small.file.limit = '0',
+ | hoodie.clustering.inline = 'true',
+ | hoodie.clustering.inline.max.commits = '2'
+ | )
+ """.stripMargin)
+
+ // small.file.limit = 0 keeps the second insert in its own file group.
Otherwise the
+ // second commit bin-packs into the first file group and rewrites it
through the CoW
+ // small-file MERGE (a different internal read stack), conflating that
path's variant
+ // handling with the clustering rewrite this test isolates.
+ spark.sql(s"insert into $tableName values " +
+ "(1, parse_json('{\"key\":\"value1\"}'), 1000), " +
+ "(2, parse_json('{\"key\":\"value2\"}'), 1000)")
+
+ // The pre-clustering base file must actually carry the shredded layout;
without this
+ // check the test silently degrades into the unshredded twin below if
the forced
+ // shredding schema ever stops taking effect.
+ val preClusteringFiles = listDataParquetFiles(tablePath)
+ assert(preClusteringFiles.nonEmpty, "Should have at least one data
parquet file before clustering")
+ preClusteringFiles.foreach { filePath =>
+ val parquetSchema = readParquetSchema(filePath)
+ val variantGroup = getFieldAsGroup(parquetSchema, "v")
+ assert(variantGroup.containsField("typed_value"),
+ s"Pre-clustering base file should carry typed_value.
Schema:\n$variantGroup")
+ }
+
+ // Second commit trips inline clustering (max.commits = 2), which
rewrites the rows
+ // of the first commit too.
+ spark.sql(s"insert into $tableName values " +
+ "(3, parse_json('{\"key\":\"value3\"}'), 1000), " +
+ "(4, parse_json('{\"key\":\"value4\"}'), 1000)")
+
+ // getLastClusteringInstant filters by action only, so a
REQUESTED/INFLIGHT instant
+ // satisfies isPresent; isCompleted confirms the rewrite finished.
+ val metaClient = createMetaClient(spark, tablePath)
+ val lastClustering =
metaClient.getActiveTimeline.getLastClusteringInstant
+ assert(lastClustering.isPresent && lastClustering.get.isCompleted,
+ "A COMPLETED clustering (replacecommit) instant must exist after
inline clustering; " +
+ "without a completed rewrite the round-trip below proves nothing")
+
+ checkAnswer(s"select id, cast(v as string), ts from $tableName order by
id")(
+ Seq(1, "{\"key\":\"value1\"}", 1000),
+ Seq(2, "{\"key\":\"value2\"}", 1000),
+ Seq(3, "{\"key\":\"value3\"}", 1000),
+ Seq(4, "{\"key\":\"value4\"}", 1000)
+ )
+
+ // VARIANT must still surface as the native type after the clustering
rewrite.
+ val variantField = spark.table(tableName).schema.find(_.name == "v").get
+ assertResult("variant")(variantField.dataType.typeName)
+ })
+ }
+
+ test("Test COW clustering preserves unshredded VARIANT values") {
+ // Companion to the shredded clustering test above, with shredding
disabled. If this
+ // passes while the shredded one fails, the loss is specific to reading
the shredded
+ // layout inside the clustering rewrite, not to variant clustering in
general.
+ assume(HoodieSparkUtils.gteqSpark4_1, "Variant clustering read-back
requires Spark 4.1 or higher")
+
+ withRecordType()(withTempDir { tmp =>
+ val tableName = generateTableName
+ val tablePath = tmp.getCanonicalPath
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | v variant,
+ | ts long
+ |) using hudi
+ | location '$tablePath'
+ | tblproperties (
+ | primaryKey = 'id',
+ | type = 'cow',
+ | preCombineField = 'ts',
+ | hoodie.parquet.variant.write.shredding.enabled = 'false',
+ | hoodie.index.type = 'INMEMORY',
+ | hoodie.parquet.small.file.limit = '0',
+ | hoodie.clustering.inline = 'true',
+ | hoodie.clustering.inline.max.commits = '2'
+ | )
+ """.stripMargin)
+
+ spark.sql(s"insert into $tableName values " +
+ "(1, parse_json('{\"key\":\"value1\"}'), 1000), " +
+ "(2, parse_json('{\"key\":\"value2\"}'), 1000)")
+
+ // Pin the layout: these files must NOT carry typed_value, or this twin
silently
+ // becomes a copy of the shredded test above and the unshredded leg of
the rewrite
+ // goes uncovered.
+ val preClusteringFiles = listDataParquetFiles(tablePath)
+ assert(preClusteringFiles.nonEmpty, "Should have at least one data
parquet file before clustering")
+ preClusteringFiles.foreach { filePath =>
+ val parquetSchema = readParquetSchema(filePath)
+ val variantGroup = getFieldAsGroup(parquetSchema, "v")
+ assert(!variantGroup.containsField("typed_value"),
+ s"Unshredded base file must not carry typed_value.
Schema:\n$variantGroup")
+ }
+
+ spark.sql(s"insert into $tableName values " +
+ "(3, parse_json('{\"key\":\"value3\"}'), 1000), " +
+ "(4, parse_json('{\"key\":\"value4\"}'), 1000)")
+
+ val metaClient = createMetaClient(spark, tablePath)
+ val lastClustering =
metaClient.getActiveTimeline.getLastClusteringInstant
+ assert(lastClustering.isPresent && lastClustering.get.isCompleted,
+ "A COMPLETED clustering (replacecommit) instant must exist after
inline clustering")
+
+ checkAnswer(s"select id, cast(v as string), ts from $tableName order by
id")(
+ Seq(1, "{\"key\":\"value1\"}", 1000),
+ Seq(2, "{\"key\":\"value2\"}", 1000),
+ Seq(3, "{\"key\":\"value3\"}", 1000),
+ Seq(4, "{\"key\":\"value4\"}", 1000)
+ )
+ })
+ }
+
+ test("Test bulk_insert row-writer round-trips VARIANT") {
+ assume(HoodieSparkUtils.gteqSpark4_0, "Variant type requires Spark 4.0 or
higher")
+
+ // bulk_insert takes the row-writer path (HoodieRowParquetWriteSupport),
which has its
+ // own variant writers (unshredded and shredded); no end-to-end test
covered it for
+ // either layout, only writer-level units
(TestHoodieRowParquetWriteSupportVariant).
+
+ // Unshredded bulk_insert: full round trip on any Spark 4.x.
+ withTempDir { tmp =>
+ val df = spark.sql(
+ """
+ |SELECT 1L AS id, parse_json('{"key":"value1"}') AS v, 1000L AS ts
+ |UNION ALL
+ |SELECT 2L AS id, parse_json('{"key":"value2"}') AS v, 1000L AS ts
+ |""".stripMargin)
+ df.write.format("hudi")
+ .option("hoodie.table.name", "variant_bulk_insert_unshredded")
+ .option("hoodie.datasource.write.recordkey.field", "id")
+ .option("hoodie.datasource.write.precombine.field", "ts")
+ .option("hoodie.datasource.write.operation", "bulk_insert")
+ .option("hoodie.datasource.write.row.writer.enable", "true")
+ .option("hoodie.parquet.variant.write.shredding.enabled", "false")
+ .mode(SaveMode.Overwrite)
+ .save(tmp.getCanonicalPath)
+
+ val readDf = spark.read.format("hudi").load(tmp.getCanonicalPath)
+ assert(readDf.schema("v").dataType.typeName == "variant",
+ s"v should round-trip as native VariantType, got
${readDf.schema("v").dataType}")
+ val rows = readDf.selectExpr("id", "cast(v as string) as
v").orderBy("id").collect()
+ assert(rows.length == 2)
+ assert(rows(0).getString(1) == "{\"key\":\"value1\"}")
+ assert(rows(1).getString(1) == "{\"key\":\"value2\"}")
+ }
+
+ // Shredded bulk_insert exercises the row-writer shredding path end to
end; reading
+ // the shredded file back needs Spark 4.1+ (SPARK-54410).
+ if (HoodieSparkUtils.gteqSpark4_1) {
+ withTempDir { tmp =>
+ val df = spark.sql(
+ """
+ |SELECT 1L AS id, parse_json('{"key":"value1"}') AS v, 1000L AS ts
+ |UNION ALL
+ |SELECT 2L AS id, parse_json('{"key":"value2"}') AS v, 1000L AS ts
+ |""".stripMargin)
+ df.write.format("hudi")
+ .option("hoodie.table.name", "variant_bulk_insert_shredded")
+ .option("hoodie.datasource.write.recordkey.field", "id")
+ .option("hoodie.datasource.write.precombine.field", "ts")
+ .option("hoodie.datasource.write.operation", "bulk_insert")
+ .option("hoodie.datasource.write.row.writer.enable", "true")
+ .option("hoodie.parquet.variant.write.shredding.enabled", "true")
+ .option("hoodie.parquet.variant.force.shredding.schema.for.test",
"key string")
+ .mode(SaveMode.Overwrite)
+ .save(tmp.getCanonicalPath)
+
+ // The written parquet must actually carry the shredded layout;
otherwise the
+ // read-back below silently validates the unshredded path a second
time.
+ val parquetFiles = listDataParquetFiles(tmp.getCanonicalPath)
+ assert(parquetFiles.nonEmpty, "Should have at least one data parquet
file")
+ parquetFiles.foreach { filePath =>
+ val schema = readParquetSchema(filePath)
+ val variantGroup = getFieldAsGroup(schema, "v")
+ assert(variantGroup.containsField("typed_value"),
+ s"bulk_insert with shredding forced should write typed_value.
Schema:\n$variantGroup")
+ }
+
+ val readDf = spark.read.format("hudi").load(tmp.getCanonicalPath)
+ val rows = readDf.selectExpr("id", "cast(v as string) as
v").orderBy("id").collect()
+ assert(rows.length == 2)
+ assert(rows(0).getString(1) == "{\"key\":\"value1\"}")
+ assert(rows(1).getString(1) == "{\"key\":\"value2\"}")
+ }
+ }
+ }
+
test("Test toHiveCompatibleSchema converts VariantType to physical struct") {
assume(HoodieSparkUtils.gteqSpark4_0, "Variant type requires Spark 4.0 or
higher")
diff --git
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
index cf6f6981cf6c..1be6bf440a74 100644
---
a/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
+++
b/hudi-spark-datasource/hudi-spark4-common/src/main/scala/org/apache/spark/sql/adapter/BaseSpark4Adapter.scala
@@ -48,7 +48,7 @@ import
org.apache.spark.sql.execution.datasources.parquet.{HoodieFormatTrait, Pa
import org.apache.spark.sql.hudi.SparkAdapter
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.sources.{BaseRelation, Filter}
-import org.apache.spark.sql.types.{BinaryType, DataType, StructType,
VariantType}
+import org.apache.spark.sql.types.{BinaryType, DataType, StructField,
StructType, VariantType}
import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}
import org.apache.spark.storage.StorageLevel
import org.apache.spark.types.variant.Variant
@@ -296,6 +296,29 @@ abstract class BaseSpark4Adapter extends SparkAdapter with
Logging {
SparkShreddingUtils.variantShreddingSchema(dataType, isTopLevel,
isObjectField))
}
+ /**
+ * Shared implementation behind [[SparkAdapter#buildFullVariantReadSchema]]
for the 4.x
+ * adapters whose parquet reader can reconstruct shredded variants (4.1+,
SPARK-54410).
+ * Spark 4.0 keeps the default None: the write-side methods above have no
version gate, so
+ * it does write shredded files, but its reader cannot rebuild them and the
projection
+ * shape would not help.
+ */
+ protected final def rewriteTopLevelVariantsForFullRead(schema: StructType):
Option[StructType] = {
+ var rewritten = false
+ val fields = schema.fields.map { f =>
+ if (isVariantType(f.dataType)) {
+ rewritten = true
+ // Mirrors RequestedVariantField.fullVariant in PushVariantIntoScan:
whole-variant
+ // access is a single child "0" at path "$" with failOnError and UTC.
+ f.copy(dataType = StructType(Array(StructField("0", VariantType,
+ metadata = VariantMetadata("$", failOnError = true, timeZoneId =
"UTC").toMetadata))))
+ } else {
+ f
+ }
+ }
+ if (rewritten) Some(StructType(fields)) else None
+ }
+
override def createShreddedVariantWriter(
shreddedStructType: StructType,
writeStruct: Consumer[InternalRow]
diff --git
a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
index 363d0b64446a..e16def5b9373 100644
---
a/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
+++
b/hudi-spark-datasource/hudi-spark4.1.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_1Adapter.scala
@@ -236,6 +236,11 @@ class Spark4_1Adapter extends BaseSpark4Adapter {
VariantMetadata.isVariantStruct(structType)
}
+ // Spark 4.1 reconstructs shredded variants on read (SPARK-54410), so opt in
to the
+ // shared rewrite; Spark 4.0 stays on the default None.
+ override def buildFullVariantReadSchema(schema: StructType):
Option[StructType] =
+ rewriteTopLevelVariantsForFullRead(schema)
+
override def buildVariantProjector(sparkDataSchema: StructType,
sparkRequiredSchema: StructType):
Option[InternalRow => InternalRow] = {
// Quick check: any required field a variant projection struct?
diff --git
a/hudi-spark-datasource/hudi-spark4.2.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_2Adapter.scala
b/hudi-spark-datasource/hudi-spark4.2.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_2Adapter.scala
index b2790091b5fd..e971c4d6fc1d 100644
---
a/hudi-spark-datasource/hudi-spark4.2.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_2Adapter.scala
+++
b/hudi-spark-datasource/hudi-spark4.2.x/src/main/scala/org/apache/spark/sql/adapter/Spark4_2Adapter.scala
@@ -236,6 +236,11 @@ class Spark4_2Adapter extends BaseSpark4Adapter {
VariantMetadata.isVariantStruct(structType)
}
+ // Spark 4.2 reconstructs shredded variants on read (SPARK-54410), so opt in
to the
+ // shared rewrite; Spark 4.0 stays on the default None.
+ override def buildFullVariantReadSchema(schema: StructType):
Option[StructType] =
+ rewriteTopLevelVariantsForFullRead(schema)
+
override def buildVariantProjector(sparkDataSchema: StructType,
sparkRequiredSchema: StructType):
Option[InternalRow => InternalRow] = {
// Quick check: any required field a variant projection struct?