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 b1639e09e11b fix(spark): read shredded variants through the CDC and
legacy streaming paths, and restore partition values there (#19583)
b1639e09e11b is described below
commit b1639e09e11b71587f2f44ac4f6ccd23909505d2
Author: voonhous <[email protected]>
AuthorDate: Thu Aug 13 16:32:44 2026 +0800
fix(spark): read shredded variants through the CDC and legacy streaming
paths, and restore partition values there (#19583)
* fix(spark): read shredded variants through the CDC and legacy streaming
paths
Writing the coverage #19578 asked for surfaced three defects of the #19556
null-read family on the query paths that do not go through catalyst:
- HoodieMergeOnReadRDDV2 base-only splits bypass the internal reader context
and read through the plain skip-merging base reader, so a shredded base
file with no logs read null variants on the legacy
(hoodie.file.group.reader.enabled=false) streaming/relation path. Splits
with variant columns now take the file-group reader branch instead.
- CDCFileGroupIterator's BASE_FILE_INSERT case reads the new base file
directly with the plain table schema, so CDC after-images of insert
commits read null variants under every supplemental logging mode. The
direct read now applies the same full-variant rewrite and restore as the
reader context (helpers factored out of
SparkFileFormatInternalRowReaderContext; context behavior unchanged).
- InternalRowToJsonStringConverter had no VariantType case, so CDC images
serialized the VariantVal bean (raw bytes as base64) instead of the
variant's JSON. Variant columns now embed as real JSON nodes via
VariantVal.toString.
Tests: CDC round trip over a shredded COW table (insert + update,
OP_KEY_ONLY and DATA_BEFORE_AFTER, shredded layout pinned; single-row table
so the in-flight avro merge-path fix #19582 is not a prerequisite), and a
legacy-path MOR streaming round trip covering both the base-only and merged
split branches over a compacted shredded base file.
* review(19583): address round-1 comments
Main code:
- HoodieMergeOnReadRDDV2: gate the base-only re-route on
sparkAdapter.buildFullVariantReadSchema(...).isDefined instead of the mere
presence of a variant column. It is None below Spark 4.1, where the file
group reader reads the same nulls, so re-routing only lost the fast path.
- HoodieMergeOnReadRDDV2: keep the fast path for splits carrying partition
values parsed off the partition path. Only requiredSchemaReaderSkipMerging
appends those (drop.partition.columns, extract-from-path, bootstrap fast
read); the file group reader branch builds its PartitionedFile with
InternalRow.empty and an empty partition schema, so re-routing such a
split
would trade null variants for null partition columns. The same gap on the
merged branch predates this change and is left for a follow-up.
- InternalRowToJsonStringConverter: match the variant column on
dt.typeName rather than SparkAdapter.isVariantType. The guard runs for
every
non-string/array/map/struct field, and resolving the adapter needs a
version
module that is absent from hudi-spark-common's own test classpath: 13 of
the
14 TestInternalRowToJsonStringConverter cases errored with
ClassNotFoundException: Spark4_2Adapter. Also fall back to the raw
rendering
when readTree rejects the variant JSON (a non-finite double renders as a
bare
NaN/Infinity token), so a CDC query degrades instead of failing.
Tests:
- TestInternalRowToJsonStringConverter: cover the JSON embedding and the
malformed-JSON fallback; suite goes 14/14 (was 13 errors before the fix).
- TestStreamingSource: write through format("hudi"), not the fully qualified
name. Only Spark4DefaultSource overrides supportsDataType to accept
VariantType and it is reachable solely via the registered short name, so
the
write was dying with UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE before the read.
Add the testLegacyIncrementalStreamSource plan guard so the test cannot
pass
through a silent fallback to the file group reader. Correct the coverage
comment (the second batch is a log-only split, not a merged one) and add a
second testStream whose own checkpoint replays from INIT, which is what
actually produces a base + log slice on this path.
- TestVariantDataType: sweep the CDC round trip over shredded and unshredded
layouts rather than adding a second copy, so the rewrite branch is
covered on
both sides. Pin the test to HoodieRecordType.SPARK: a cdc table always
writes
through FileGroupReaderBasedMergeHandle, whose reader context follows the
merger's record type, and the AVRO leg's shredded base-file read is the
separate defect tracked as #19567/#19582. The previous claim that a
single-row
table removed that prerequisite was wrong: what fails is the CDC before
image
being written with value=null, which does not depend on carried-over rows.
Verified on Spark 4.1: TestVariantDataType 13/13 (2 cancelled as Spark-3
only),
TestStreamingSource 15/15. TestStreamingSource's suite-level
leaked-file-stream
abort reproduces identically on the pre-PR revision, so it is not from this
change.
* fix(spark): append partition values on the MOR RDD file-group-reader
branch
HoodieMergeOnReadRDDV2's file-group-reader branch sourced every projected
column from the data files, so it returned NULL partition columns whenever
those columns are not persisted there: drop.partition.columns, read-side
extraction from the partition path, or a bootstrap data-queries-only read.
SparkFileFormatInternalRowReaderContext builds its PartitionedFile with
InternalRow.empty and an empty partition schema, and the builder's
withPartitionPath only feeds the bootstrap merge, so nothing injected them.
Only the skip-merging fast path ever appended them, via
appendPartitionValues.
The output row shape was already correct -- TableSchemaResolver re-appends
dropped partition columns to the table schema, so the required schema keeps
them and the reader merely fills them with nulls -- which makes this a value
substitution at existing ordinals, with no column added, moved or dropped.
- Carry the parsed values on HoodieMergeOnReadFileSplit, resolved on the
driver
by the same routine the skip-merging reader relies on. A log-only slice
has no
base file to carry them, so they come off a log file's path; that uses
HoodieLogFile#getPath, since pathInfo is transient and null there.
- Splice them in with one per-split UnsafeProjection over a JoinedRow. Bound
references rather than literals: literals are inlined into generated
code, so
every distinct partition value would miss Spark's codegen cache. Mirrors
HoodieFileGroupReaderBasedFileFormat.appendPartitionAndProject.
- No-op unless it has to run: the split carries no values when the columns
are
read from the data files, the ordinal array is empty for a non-partitioned
table (including the metadata table), and unprojected partition columns
resolve to -1. The metadata-table Avro leg is untouched.
With the branch fixed, the base-only re-route added earlier in this PR no
longer has to avoid it, so its partition-value escape hatch is dropped and
splits with shredded variant columns re-route unconditionally.
Reachable today only from legacy MOR streaming
(hoodie.file.group.reader.enabled
=false); batch MOR always scans through
HoodieFileGroupReaderBasedFileFormat,
which appends partition values itself.
Tests, in TestLegacyParquetReadPath since it already constructs the legacy
relations directly:
- MOR snapshot over drop.partition.columns with a partial upsert, so one
file
group merges base plus log while the others stay base-only. Verified red
before this change (base-served p0 rows came back null) and green after.
- MOR incremental over a log-only slice. This one passes with or without the
splicing above, so it is not a repro: its rows come from log records,
which do
carry the partition column because HUDI-6926 makes a MOR upsert ignore
drop.partition.columns. It pins the log-only resolution off
HoodieLogFile#getPath and agreement with the file-group-reader oracle.
Noted
as such in the test.
* review(19583): correct the variant image fallback rationale
Round-2 review pointed out the stated trigger was wrong, and it is: Spark
guards the double and float arms of Variant.toJsonImpl with isFinite and
sends
the non-finite one through appendQuoted, so a variant renders NaN and
Infinity
QUOTED and readTree never sees a bare token. Verified against the bytecode
and
by rendering hand-built variants on Spark 4.0.2 and 4.1.1.
The fallback still earns its place, for a different reason. Jackson's
default
StreamReadConstraints cap field names at 50k chars, strings at 20M and
nesting
at 1000 levels, while a variant may hold all three well inside its own 128MB
limit, and castToVariant applies no such gate. All three were reproduced and
all three arrive as StreamConstraintsException, a JsonProcessingException,
so
the existing catch covers them.
- Reword the comment to name the real triggers.
- Record why value.toString stays outside the try: it throws
MALFORMED_VARIANT
(a SparkRuntimeException, which JsonProcessingException does not cover) on
corrupt bytes. That is a data-integrity signal an operator needs, not a
rendering quirk to swallow into a CDC image, and there would be no
rendering
left to fall back to anyway.
- Retarget the test, which asserted a rendering production cannot produce,
onto
over-deep nesting, and rename it accordingly. It expects the value as a
JSON
string, so it only passes when the fallback actually fires.
* review(19583): embed the variant image verbatim and correct two comments
Round-3 review. The write-side gap is real: Jackson enforces a nesting cap
on
output too (StreamWriteConstraints, also 1000 levels), inside convert's
writeValueAsString and therefore outside the branch's catch, so a variant
deep
enough to clear the read limit but not the write limit once the image's own
object levels are added still failed the query. Embedding the validated
rendering as a RawValue closes it: that goes out through
JsonGenerator.writeRawValue, which keeps no nesting context at all.
Measured while confirming this, on jackson 2.18.2/2.20.0/2.21.2: the window
is
one level per enclosing object for an object-rendered variant and one more
for
an array-rendered one, because jackson-core's WriterBasedJsonGenerator
validates the PARENT depth in writeStartObject(Object) and so tolerates one
extra level. A variant at the top of the image rendering as an array fails
at
exactly depth 1000, which is what the new test pins.
RawValue is byte-identical to the parsed-node path for every rendering
Variant.toJson can produce -- checked against the real renderer across
decimals, doubles, escaping, key order and all scalar shapes -- and it
drops a
parse-then-rebuild round trip. It does mean unvalidated text would be
spliced
verbatim, so the gate is tightened to match:
- FAIL_ON_TRAILING_TOKENS on the mapper, since readTree otherwise parses a
valid prefix and leaves the rest unconsumed;
- an explicit MissingNode check, since readTree answers blank input with
MissingNode rather than throwing, and RawValue would emit nothing at all.
Neither is producible by Variant.toJson, which always emits exactly one
complete value; they are defence in depth for the verbatim embed.
Also, from the same review:
- MergeOnReadSnapshotRelation: the split's partition values are non-empty on
any of shouldExtractPartitionValuesFromPartitionPath's three triggers, not
only when the columns are omitted from the data files -- the
extract-from-path
read option applies to tables that persist them too. Comment corrected.
- TestVariantDataType: the compaction test still pointed at #19578 as
tracking
the no-catalyst-schema legs, which this PR closes and covers. Repointed
at the
CDC round trip and the TestStreamingSource test.
Verified: TestInternalRowToJsonStringConverter 16/16, with the depth test
red
before the RawValue change (StreamWriteConstraints exceeded) and green
after;
TestVariantDataType 13/13, so the CDC round trip still reads its images back
through get_json_object.
---
.../SparkFileFormatInternalRowReaderContext.scala | 52 ++++++----
.../scala/org/apache/hudi/HoodieBaseRelation.scala | 11 ++-
.../org/apache/hudi/HoodieMergeOnReadRDDV2.scala | 77 ++++++++++++++-
.../apache/hudi/MergeOnReadSnapshotRelation.scala | 15 ++-
.../org/apache/hudi/cdc/CDCFileGroupIterator.scala | 20 +++-
.../cdc/InternalRowToJsonStringConverter.scala | 45 +++++++++
.../cdc/TestInternalRowToJsonStringConverter.scala | 79 ++++++++++++++-
.../functional/TestLegacyParquetReadPath.scala | 106 ++++++++++++++++++++-
.../hudi/functional/TestStreamingSource.scala | 105 +++++++++++++++++++-
.../sql/hudi/dml/schema/TestVariantDataType.scala | 106 ++++++++++++++++++++-
10 files changed, 574 insertions(+), 42 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 5752bcfea918..acab0f7a0bbc 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
@@ -180,14 +180,8 @@ class
SparkFileFormatInternalRowReaderContext(baseFileReader: SparkColumnarFileR
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])
- }
+
SparkFileFormatInternalRowReaderContext.fullVariantReadSchemaWithOrdinals(parquetReadStructType)
+ .getOrElse((parquetReadStructType, Set.empty[Int]))
} else {
(parquetReadStructType, Set.empty[Int])
}
@@ -479,15 +473,28 @@ object SparkFileFormatInternalRowReaderContext {
}
/**
- * 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.
+ * Rewrites top-level VariantType fields of `structType` into the
full-variant projection
+ * shape (see SparkAdapter.buildFullVariantReadSchema) and returns it with
the ordinals of
+ * the rewritten fields, or None when nothing rewrites (no variant fields,
or no shredded
+ * read support on this Spark version). Shared by this context and direct
base-file reads
+ * that bypass it (CDCFileGroupIterator's BASE_FILE_INSERT case).
*/
- private[hudi] def wrapWithVariantRestore(
- iterator: ClosableIterator[InternalRow],
- readSchema: StructType,
- variantOrdinals: Set[Int]): ClosableIterator[InternalRow] = {
+ private[hudi] def fullVariantReadSchemaWithOrdinals(structType: StructType):
Option[(StructType, Set[Int])] = {
+
SparkAdapterSupport.sparkAdapter.buildFullVariantReadSchema(structType).map {
rewritten =>
+ val ordinals = rewritten.fields.indices
+ .filter(i => rewritten.fields(i).dataType !=
structType.fields(i).dataType)
+ .toSet
+ (rewritten, ordinals)
+ }
+ }
+
+ /**
+ * Projection restoring native VariantType columns from the full-variant
projection shape:
+ * each rewritten column is a struct with a single child "0" holding the
reconstructed
+ * variant, so restoring is a projection of that child. NOTE:
UnsafeProjection reuses its
+ * output buffer; callers that buffer rows must copy them.
+ */
+ private[hudi] def variantRestoreProjection(readSchema: StructType,
variantOrdinals: Set[Int]): UnsafeProjection = {
val exprs: Seq[Expression] = readSchema.fields.zipWithIndex.map { case
(field, i) =>
val ref = BoundReference(i, field.dataType, field.nullable)
if (variantOrdinals.contains(i)) {
@@ -496,7 +503,18 @@ object SparkFileFormatInternalRowReaderContext {
ref: Expression
}
}.toSeq
- val projection = UnsafeProjection.create(exprs)
+ UnsafeProjection.create(exprs)
+ }
+
+ /**
+ * Restores native VariantType columns from the full-variant projection
shape requested for
+ * internal reads of parquet base files (see
SparkAdapter.buildFullVariantReadSchema).
+ */
+ private[hudi] def wrapWithVariantRestore(
+ iterator: ClosableIterator[InternalRow],
+ readSchema: StructType,
+ variantOrdinals: Set[Int]): ClosableIterator[InternalRow] = {
+ val projection = variantRestoreProjection(readSchema, variantOrdinals)
new ClosableIterator[InternalRow] {
override def hasNext: Boolean = iterator.hasNext
override def next(): InternalRow = projection(iterator.next())
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieBaseRelation.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieBaseRelation.scala
index e1e01103bdf2..3a0a363fa2eb 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieBaseRelation.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieBaseRelation.scala
@@ -427,11 +427,14 @@ abstract class HoodieBaseRelation(val sqlContext:
SQLContext,
* and pass this reader on parquet file. So that, we can query the partition
columns.
*/
+ protected def getPartitionColumnsAsInternalRow(filePath: StoragePath):
InternalRow =
+ getPartitionColumnsAsInternalRowInternal(filePath, metaClient.getBasePath,
shouldExtractPartitionValuesFromPartitionPath)
+
protected def getPartitionColumnsAsInternalRow(file: StoragePathInfo):
InternalRow =
- getPartitionColumnsAsInternalRowInternal(file, metaClient.getBasePath,
shouldExtractPartitionValuesFromPartitionPath)
+ getPartitionColumnsAsInternalRow(file.getPath)
protected def getPartitionColumnValuesAsInternalRow(file: StoragePathInfo):
InternalRow =
- getPartitionColumnsAsInternalRowInternal(file,
+ getPartitionColumnsAsInternalRowInternal(file.getPath,
metaClient.getBasePath, extractPartitionValuesFromPartitionPath = true)
protected def usePartitionValueExtractorOnRead(optParams: Map[String,
String], sparkSession: SparkSession): Boolean = {
@@ -439,11 +442,11 @@ abstract class HoodieBaseRelation(val sqlContext:
SQLContext,
DataSourceReadOptions.USE_PARTITION_VALUE_EXTRACTOR_ON_READ.defaultValue).toBoolean
}
- protected def getPartitionColumnsAsInternalRowInternal(file:
StoragePathInfo, basePath: StoragePath,
+ protected def getPartitionColumnsAsInternalRowInternal(filePath:
StoragePath, basePath: StoragePath,
extractPartitionValuesFromPartitionPath: Boolean): InternalRow = {
if (extractPartitionValuesFromPartitionPath) {
val tablePathWithoutScheme = basePath.getPathWithoutSchemeAndAuthority
- val partitionPathWithoutScheme =
file.getPath.getParent.getPathWithoutSchemeAndAuthority
+ val partitionPathWithoutScheme =
filePath.getParent.getPathWithoutSchemeAndAuthority
val relativePath =
tablePathWithoutScheme.toUri.relativize(partitionPathWithoutScheme.toUri).toString
val timeZoneId = conf.get("timeZone",
sparkSession.sessionState.conf.sessionLocalTimeZone)
val rowValues = HoodieSparkUtils.parsePartitionColumnValues(
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala
index 3ae45815c679..267b4e4389a6 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieMergeOnReadRDDV2.scala
@@ -33,6 +33,7 @@ import org.apache.hudi.common.table.log.InstantRange.RangeType
import org.apache.hudi.common.table.read.{HoodieFileGroupReader,
HoodieRecordReader}
import org.apache.hudi.common.table.read.lsm.{HoodieLsmFileGroupReader,
LsmReaderUtils}
import org.apache.hudi.common.util.{Option => HOption}
+import org.apache.hudi.common.util.ValidationUtils.checkState
import org.apache.hudi.common.util.collection.ClosableIterator
import
org.apache.hudi.hadoop.utils.HoodieRealtimeRecordReaderUtils.getMaxCompactionMemoryInBytes
import
org.apache.hudi.metadata.HoodieTableMetadata.getDataTableBasePathFromMetadataTable
@@ -47,7 +48,7 @@ import org.apache.spark.{HoodieSparkInputMetricsUtils,
Partition, SerializableWr
import org.apache.spark.broadcast.Broadcast
import org.apache.spark.rdd.RDD
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.UnsafeProjection
+import org.apache.spark.sql.catalyst.expressions.{BoundReference, Expression,
JoinedRow, UnsafeProjection}
import org.apache.spark.sql.execution.datasources.{FileFormat,
SparkColumnarFileReader}
import org.apache.spark.sql.hudi.MultipleColumnarFileFormatReader
import org.apache.spark.sql.internal.SQLConf
@@ -148,12 +149,35 @@ class HoodieMergeOnReadRDDV2(@transient sc: SparkContext,
}
}
+ // The plain skip-merging reader cannot read a SHREDDED variant base file:
it requests native
+ // VariantType, which clips the shredded group to {metadata, value} and
reads value=null (the
+ // #19556 defect family). Such splits take the file-group reader below,
whose reader context
+ // requests the full-variant projection shape instead (#19578). Keyed off
the adapter building
+ // that shape rather than the mere presence of a variant column: it is None
below Spark 4.1,
+ // where the file-group reader would read the same nulls, so re-routing
there would cost the
+ // fast path for nothing.
+ private val shouldRerouteVariantSplit: Boolean =
+
sparkAdapter.buildFullVariantReadSchema(requiredSchema.structTypeSchema).isDefined
+
+ // Ordinal each table partition column occupies in the required schema, or
-1 when it is not
+ // projected. Indexed by the table-config partition-field order, i.e. the
very order in which
+ // HoodieBaseRelation#getPartitionColumnsAsInternalRow emits the values
carried by a split.
+ // Resolved on the driver: only this array is shipped to the executors.
+ private val partitionColumnOrdinals: Array[Int] = {
+ val caseSensitive = sqlConf.caseSensitiveAnalysis
+ val requiredFieldNames = requiredSchema.structTypeSchema.fieldNames
+
metaClient.getTableConfig.getPartitionFields.orElse(Array.empty[String]).map {
partitionColumn =>
+ requiredFieldNames.indexWhere(fieldName =>
+ if (caseSensitive) fieldName == partitionColumn else
fieldName.equalsIgnoreCase(partitionColumn))
+ }
+ }
+
override def compute(split: Partition, context: TaskContext):
Iterator[InternalRow] = {
val partition = split.asInstanceOf[HoodieMergeOnReadPartition]
val bytesReadCallback =
HoodieSparkInputMetricsUtils.getFSBytesReadOnThreadCallback()
val iter: Iterator[InternalRow] = partition.split match {
- case dataFileOnlySplit if dataFileOnlySplit.logFiles.isEmpty =>
+ case dataFileOnlySplit if dataFileOnlySplit.logFiles.isEmpty &&
!shouldRerouteVariantSplit =>
val projectedReader =
projectReader(fileReaders.requiredSchemaReaderSkipMerging,
requiredSchema.structTypeSchema)
projectedReader(dataFileOnlySplit.dataFile.get)
@@ -220,7 +244,7 @@ class HoodieMergeOnReadRDDV2(@transient sc: SparkContext,
.withInternalSchemaOpt(HOption.ofNullable(tableSchema.internalSchema.orNull))
.build()
}
- convertCloseableIterator(fileGroupReader.getClosableIterator)
+ convertCloseableIterator(fileGroupReader.getClosableIterator,
partition.split.partitionValues)
}
}
@@ -265,16 +289,59 @@ class HoodieMergeOnReadRDDV2(@transient sc: SparkContext,
}
}
- private def convertCloseableIterator(closeableFileGroupRecordIterator:
ClosableIterator[InternalRow]): Iterator[InternalRow] = {
+ private def convertCloseableIterator(closeableFileGroupRecordIterator:
ClosableIterator[InternalRow],
+ partitionValues: InternalRow):
Iterator[InternalRow] = {
+ // NOTE: built here, i.e. once per split on the executor -- a projection
is not serializable.
+ val mapper: InternalRow => InternalRow =
partitionValueMapper(partitionValues).getOrElse(identity)
new Iterator[InternalRow] with Closeable {
override def hasNext: Boolean = closeableFileGroupRecordIterator.hasNext
- override def next(): InternalRow =
closeableFileGroupRecordIterator.next()
+ override def next(): InternalRow =
mapper(closeableFileGroupRecordIterator.next())
override def close(): Unit = closeableFileGroupRecordIterator.close()
}
}
+ /**
+ * Builds the mapper splicing a split's partition values into the rows the
file-group reader
+ * produces, or None when there is nothing to splice.
+ *
+ * The file-group reader sources every projected column from the data files,
so it hands back
+ * nulls for the partition columns whenever those are not persisted there
+ * ([["hoodie.datasource.write.drop.partition.columns"]], read-side
extraction from the partition
+ * path, or a bootstrap data-queries-only read). Only the values parsed off
the partition path
+ * carry them, which is what a split holds.
+ *
+ * Binding at existing ordinals is sufficient because the output row shape
is already correct:
+ * [[org.apache.hudi.common.table.TableSchemaResolver]] re-appends dropped
partition columns to
+ * the table schema, so the required schema keeps them and the reader merely
fills them with
+ * nulls. No column is added, moved or dropped here.
+ *
+ * @param partitionValues values parsed off the partition path, in
table-config partition-field
+ * order (matching [[partitionColumnOrdinals]]),
empty when the partition
+ * columns are read from the data files as usual
+ */
+ private def partitionValueMapper(partitionValues: InternalRow):
Option[InternalRow => InternalRow] = {
+ if (partitionValues.numFields == 0 || partitionColumnOrdinals.forall(_ <
0)) {
+ None
+ } else {
+ checkState(partitionValues.numFields == partitionColumnOrdinals.length,
+ s"Expected ${partitionColumnOrdinals.length} partition values but got
${partitionValues.numFields}")
+ val requiredFields = requiredSchema.structTypeSchema.fields
+ // NOTE: The partition values are bound as references into the right
half of a JoinedRow rather
+ // than substituted as literals: literals are inlined into the
generated code, so every
+ // distinct partition value would miss Spark's codegen cache.
+ val projectedFields: Seq[Expression] = requiredFields.zipWithIndex.map {
case (field, ordinal) =>
+ val partitionFieldIdx = partitionColumnOrdinals.indexOf(ordinal)
+ val boundOrdinal = if (partitionFieldIdx >= 0) requiredFields.length +
partitionFieldIdx else ordinal
+ BoundReference(boundOrdinal, field.dataType, field.nullable)
+ }.toSeq
+ val projection = UnsafeProjection.create(projectedFields)
+ val joinedRow = new JoinedRow()
+ Some((row: InternalRow) => projection.apply(joinedRow(row,
partitionValues)))
+ }
+ }
+
private def withInputMetrics(iter: Iterator[InternalRow],
closeableIter: Iterator[InternalRow],
context: TaskContext,
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadSnapshotRelation.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadSnapshotRelation.scala
index aa96512ca18f..f44891619274 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadSnapshotRelation.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/MergeOnReadSnapshotRelation.scala
@@ -37,7 +37,8 @@ import org.apache.spark.sql.types.StructType
import scala.collection.JavaConverters._
case class HoodieMergeOnReadFileSplit(dataFile: Option[PartitionedFile],
- logFiles: List[HoodieLogFile]) extends
HoodieFileSplit
+ logFiles: List[HoodieLogFile],
+ partitionValues: InternalRow =
InternalRow.empty) extends HoodieFileSplit
case class MergeOnReadSnapshotRelation(override val sqlContext: SQLContext,
override val optParams: Map[String,
String],
@@ -132,7 +133,17 @@ abstract class BaseMergeOnReadSnapshotRelation(sqlContext:
SQLContext,
getPartitionColumnsAsInternalRow(file.getPathInfo),
file.getPathInfo.getPath, 0, file.getFileSize)
}
- HoodieMergeOnReadFileSplit(partitionedBaseFile, logFiles)
+ // Non-empty exactly when a reader has to take the partition columns off
the path rather than the
+ // data files, i.e. on any of
shouldExtractPartitionValuesFromPartitionPath's triggers: dropped
+ // partition columns, the extract-from-path read option (which also
covers tables that do persist
+ // them), or a bootstrap data-queries-only read. A log-only slice still
lives in the partition
+ // directory, so a log file's path resolves them just as the base file
does.
+ // NOTE: HoodieLogFile#getPathInfo is transient and null for log files
built from a path.
+ val partitionValues =
partitionedBaseFile.map(_.partitionValues).getOrElse {
+ logFiles.headOption.map(f =>
getPartitionColumnsAsInternalRow(f.getPath)).getOrElse(InternalRow.empty)
+ }
+
+ HoodieMergeOnReadFileSplit(partitionedBaseFile, logFiles,
partitionValues)
}.toList
}
}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
index 44630e8d5bbc..b845dbe38d35 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/CDCFileGroupIterator.scala
@@ -378,8 +378,24 @@ class CDCFileGroupIterator(split: HoodieCDCFileGroupSplit,
val pf = sparkPartitionedFileUtils.createPartitionedFile(
InternalRow.empty, absCDCPath, 0, fileStatus.getLength)
- recordIter = baseFileReader.read(pf,
originTableSchema.structTypeSchema, new StructType(),
- toJavaOption(originTableSchema.internalSchema), Seq.empty, conf,
tableSchemaOpt)
+ // This read bypasses SparkFileFormatInternalRowReaderContext, so it
needs the same
+ // full-variant treatment that context applies: requesting native
VariantType against
+ // a SHREDDED base file clips the shredded group to {metadata,
value} and reads
+ // value=null, which would surface as null variants in the insert
after-images
+ // (#19556 family, #19578). The restore projection reuses one output
buffer, hence
+ // the copy before buffering.
+ val baseRows = SparkFileFormatInternalRowReaderContext
+
.fullVariantReadSchemaWithOrdinals(originTableSchema.structTypeSchema) match {
+ case Some((rewritten, ordinals)) =>
+ val restore =
SparkFileFormatInternalRowReaderContext.variantRestoreProjection(rewritten,
ordinals)
+ baseFileReader.read(pf, rewritten, new StructType(),
+ toJavaOption(originTableSchema.internalSchema), Seq.empty,
conf, tableSchemaOpt)
+ .map(row => restore(row).copy(): InternalRow)
+ case None =>
+ baseFileReader.read(pf, originTableSchema.structTypeSchema, new
StructType(),
+ toJavaOption(originTableSchema.internalSchema), Seq.empty,
conf, tableSchemaOpt)
+ }
+ recordIter = baseRows
.map(record => BufferedRecords.fromEngineRecord(record, schema,
readerContext.getRecordContext, orderingFieldNames, false))
case BASE_FILE_DELETE =>
assert(currentCDCFileSplit.getBeforeFileSlice.isPresent)
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/InternalRowToJsonStringConverter.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/InternalRowToJsonStringConverter.scala
index 85c69548533d..4d01831e9a8c 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/InternalRowToJsonStringConverter.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/cdc/InternalRowToJsonStringConverter.scala
@@ -19,7 +19,9 @@
package org.apache.hudi.cdc
import com.fasterxml.jackson.annotation.JsonInclude.Include
+import com.fasterxml.jackson.core.JsonProcessingException
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
+import com.fasterxml.jackson.databind.util.RawValue
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.util.{ArrayData, MapData}
@@ -32,6 +34,9 @@ class InternalRowToJsonStringConverter(schema: StructType) {
val _mapper = new ObjectMapper
_mapper.setSerializationInclusion(Include.NON_ABSENT)
_mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+ // The variant branch below embeds its input verbatim once readTree
accepts it, and readTree on
+ // its own is happy to parse a valid prefix and leave the rest unconsumed.
+ _mapper.configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, true)
_mapper.registerModule(DefaultScalaModule)
_mapper
}
@@ -90,6 +95,37 @@ class InternalRowToJsonStringConverter(schema: StructType) {
structMap.toMap
case _ => value // fallback
}
+ case dt if dt.typeName ==
InternalRowToJsonStringConverter.VARIANT_TYPE_NAME =>
+ // VariantVal.toString renders the variant as JSON; embed that
rendering verbatim so the
+ // image carries the variant's structure. Falling through to the
default would serialize
+ // the VariantVal bean, i.e. its raw value/metadata bytes as base64.
+ // Matched on the type name rather than SparkAdapter.isVariantType:
this guard is
+ // evaluated for every non-string/array/map/struct field, and
resolving the adapter
+ // needs a version module that is not on hudi-spark-common's own
test classpath.
+ val variantJson = value.toString
+ try {
+ // readTree is a validation gate only, and its result is
discarded: embedding the parsed
+ // node instead would re-serialize the tree through the generator,
and Jackson's write-side
+ // nesting cap (StreamWriteConstraints, also 1000) is enforced by
writeValueAsString in
+ // convert, outside this block. A variant deep enough to clear the
read limit but not the
+ // write limit once the image's own object levels are added would
fail the query there.
+ // RawValue goes out through JsonGenerator.writeRawValue, which
keeps no nesting context.
+ val parsed = mapper.readTree(variantJson)
+ // readTree accepts blank input as a MissingNode instead of
throwing, and RawValue would
+ // then emit nothing at all, leaving a malformed image.
Trailing-token input is refused by
+ // FAIL_ON_TRAILING_TOKENS above, which readTree does not check on
its own.
+ if (parsed == null || parsed.isMissingNode) variantJson else new
RawValue(variantJson)
+ } catch {
+ // A variant can hold a field name, string or nesting depth past
Jackson's default
+ // StreamReadConstraints (50k chars, 20M chars, 1000 levels) while
staying well inside
+ // the variant size limit, and all three arrive here as
StreamConstraintsException. A
+ // CDC image is diagnostic data rather than the table's data, so
keep the rendering as
+ // a plain string instead of failing the query over it.
+ // NOTE: value.toString is deliberately outside this block. It
throws MALFORMED_VARIANT
+ // on corrupt bytes, which is a data-integrity problem an operator
has to see, not a
+ // rendering quirk to paper over -- and there would be no
rendering left to fall back to.
+ case _: JsonProcessingException => variantJson
+ }
case _ =>
// For primitive types and other unsupported types, return as is
value
@@ -97,3 +133,12 @@ class InternalRowToJsonStringConverter(schema: StructType) {
}
}
}
+
+object InternalRowToJsonStringConverter {
+
+ /**
+ * Type name of Spark's VariantType. Matched by name so this module, which
also compiles
+ * against Spark 3 where the type does not exist, needs neither the symbol
nor a SparkAdapter.
+ */
+ private val VARIANT_TYPE_NAME = "variant"
+}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/cdc/TestInternalRowToJsonStringConverter.scala
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/cdc/TestInternalRowToJsonStringConverter.scala
index 0c9bb613184f..7ea8cae4bc40 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/cdc/TestInternalRowToJsonStringConverter.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/hudi/cdc/TestInternalRowToJsonStringConverter.scala
@@ -18,15 +18,16 @@
package org.apache.hudi.cdc
-import org.apache.hudi.HoodieTableSchema
+import org.apache.hudi.{HoodieSparkUtils, HoodieTableSchema}
import org.apache.hudi.common.schema.HoodieSchema
import org.apache.hudi.common.schema.internal.InternalSchema
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.util.{ArrayBasedMapData, ArrayData}
-import org.apache.spark.sql.types.{ArrayType, DataTypes, MapType, Metadata,
StructField, StructType}
+import org.apache.spark.sql.types.{ArrayType, DataType, DataTypes, MapType,
Metadata, StructField, StructType}
import org.apache.spark.unsafe.types.UTF8String
import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
+import org.junit.jupiter.api.Assumptions.assumeTrue
import org.junit.jupiter.api.Test
class TestInternalRowToJsonStringConverter {
@@ -167,6 +168,80 @@ class TestInternalRowToJsonStringConverter {
assertEquals("""{"id":8,"name":"empty_map_test","properties":{}}""",
converted.toString)
}
+ @Test
+ def variantColumnEmbedsItsJson(): Unit = {
+ assumeTrue(HoodieSparkUtils.gteqSpark4_0, "VariantType requires Spark 4.0
or higher")
+ // VariantVal.toString renders the variant as JSON, so the image must
carry that structure
+ // rather than a bean rendering of the raw value/metadata bytes.
+ val row = InternalRow.fromSeq(Seq(1,
variantRendering("""{"key":"value1"}""")))
+ assertEquals("""{"id":1,"v":{"key":"value1"}}""",
+ new
InternalRowToJsonStringConverter(variantSchema.structTypeSchema).convert(row).toString)
+ }
+
+ @Test
+ def variantColumnFallsBackToRawRenderingWhenJacksonRejectsIt(): Unit = {
+ assumeTrue(HoodieSparkUtils.gteqSpark4_0, "VariantType requires Spark 4.0
or higher")
+ // Jackson's default StreamReadConstraints cap nesting at 1000 levels,
field names at 50k chars
+ // and strings at 20M; a variant can exceed all three well inside its own
size limit, and
+ // VariantVal.toString renders it faithfully. The image keeps that
rendering rather than failing
+ // the whole CDC query. Nesting is the cheapest of the three to provoke.
+ val tooDeeplyNested = ("[" * 1001) + "1" + ("]" * 1001)
+ val row = InternalRow.fromSeq(Seq(1, variantRendering(tooDeeplyNested)))
+ assertEquals(s"""{"id":1,"v":"$tooDeeplyNested"}""",
+ new
InternalRowToJsonStringConverter(variantSchema.structTypeSchema).convert(row).toString)
+ }
+
+ @Test
+ def variantColumnSurvivesJacksonWriteSideNestingCap(): Unit = {
+ assumeTrue(HoodieSparkUtils.gteqSpark4_0, "VariantType requires Spark 4.0
or higher")
+ // Jackson caps nesting on the write side too, and the image adds its own
object level on top of
+ // the variant, so a depth the read side accepts could still fail in
writeValueAsString -- outside
+ // the fallback, taking the whole CDC query with it. The rendering is
embedded verbatim rather
+ // than as a parsed node precisely so that write-side accounting never
sees it.
+ val atReadLimit = ("[" * 1000) + "1" + ("]" * 1000)
+ val row = InternalRow.fromSeq(Seq(1, variantRendering(atReadLimit)))
+ val image = new
InternalRowToJsonStringConverter(variantSchema.structTypeSchema).convert(row).toString
+ // Embedded as a real array, i.e. neither the fallback nor a write-side
failure.
+ assertEquals(s"""{"id":1,"v":$atReadLimit}""", image)
+ }
+
+ @Test
+ def variantColumnFallsBackWhenRenderingHasTrailingTokens(): Unit = {
+ assumeTrue(HoodieSparkUtils.gteqSpark4_0, "VariantType requires Spark 4.0
or higher")
+ // readTree parses a valid prefix and leaves the rest, so without
FAIL_ON_TRAILING_TOKENS a
+ // verbatim embed would splice the trailing text straight into the image
and corrupt it.
+ val row = InternalRow.fromSeq(Seq(1, variantRendering("""{"a":1}
trailing""")))
+ assertEquals("""{"id":1,"v":"{\"a\":1} trailing"}""",
+ new
InternalRowToJsonStringConverter(variantSchema.structTypeSchema).convert(row).toString)
+ }
+
+ /**
+ * Stands in for a VariantVal: the converter reaches the value only through
toString, which is
+ * what renders a real variant as JSON. Constructing a genuine VariantVal
here would need
+ * Spark-4-only symbols, and this module compiles against Spark 3 as well;
the end-to-end
+ * behaviour over real variants is covered by the CDC round trip in
TestVariantDataType.
+ *
+ * Note this stands in only for renderings a real variant can produce. Spark
quotes non-finite
+ * doubles (Variant.toJsonImpl guards them with isFinite and takes the
appendQuoted arm), so no
+ * variant renders a bare NaN or Infinity token.
+ */
+ private def variantRendering(json: String): AnyRef = new AnyRef {
+ override def toString: String = json
+ }
+
+ private def variantSchema: HoodieTableSchema = {
+ val structTypeSchema = new StructType(Array[StructField](
+ StructField("id", DataTypes.IntegerType, nullable = false,
Metadata.empty),
+ StructField("v", DataType.fromDDL("variant"), nullable = true,
Metadata.empty)))
+ val avroSchemaStr: String =
+ """{"type": "record", "name": "test", "fields": [
+ |{"name": "id", "type": "int"},
+ |{"name": "v", "type": {"type": "record", "name": "variant",
"logicalType": "variant",
+ | "fields": [{"name": "metadata", "type": "bytes"}, {"name": "value",
"type": "bytes"}]}}
+ |]}""".stripMargin
+ HoodieTableSchema(structTypeSchema, HoodieSchema.parse(avroSchemaStr),
Option.empty[InternalSchema])
+ }
+
private def hoodieTableSchema: HoodieTableSchema = {
val structTypeSchema = new StructType(Array[StructField](
StructField("uuid", DataTypes.IntegerType, nullable = false,
Metadata.empty),
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala
index fac97a0cbecb..1cac6b2f7f25 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala
@@ -17,11 +17,11 @@
package org.apache.hudi.functional
-import org.apache.hudi.{BaseFileOnlyRelation, DataSourceReadOptions,
DataSourceWriteOptions, IncrementalRelationV1, IncrementalRelationV2,
ScalaAssertionSupport}
+import org.apache.hudi.{BaseFileOnlyRelation, DataSourceReadOptions,
DataSourceWriteOptions, IncrementalRelationV1, IncrementalRelationV2,
MergeOnReadIncrementalRelationV2, MergeOnReadSnapshotRelation,
ScalaAssertionSupport}
import org.apache.hudi.common.config.HoodieReaderConfig
import org.apache.hudi.common.table.HoodieTableConfig
import org.apache.hudi.common.table.log.InstantRange.RangeType
-import org.apache.hudi.config.HoodieWriteConfig
+import org.apache.hudi.config.{HoodieCompactionConfig, HoodieWriteConfig}
import org.apache.hudi.testutils.HoodieSparkClientTestBase
import org.apache.spark.sql.{DataFrame, Row, SaveMode, SparkSession}
@@ -156,6 +156,36 @@ class TestLegacyParquetReadPath extends
HoodieSparkClientTestBase with ScalaAsse
assertEquals(expected, actual)
}
+ // Inline compaction is switched on automatically for batch MOR writes
(HoodieSparkSqlWriter), so it
+ // is pinned off here to keep the second deltacommit in a log file rather
than a rewritten base file.
+ private val morDropPartitionColumnsOpts = Map(
+ DataSourceWriteOptions.TABLE_TYPE.key ->
DataSourceWriteOptions.MOR_TABLE_TYPE_OPT_VAL,
+ HoodieTableConfig.DROP_PARTITION_COLUMNS.key -> "true",
+ HoodieCompactionConfig.INLINE_COMPACT.key -> "false")
+
+ /**
+ * MOR table whose base files do not carry the partition column, so its
values exist only on the
+ * partition path. Deltacommit 1 inserts ids 1..30 across p0/p1/p2;
deltacommit 2 upserts a strict
+ * subset of the p0 rows, so the p0 file group gains a log file while p1/p2
stay base-only.
+ *
+ * Both "strict subset" and "single partition" are deliberate: the p1/p2
groups keep the
+ * skip-merging fast path in the same query, and the p0 rows left untouched
by deltacommit 2 are
+ * served from the base file, which is where the partition column is
missing. Note that
+ * HoodieSparkSqlWriter forces drop.partition.columns off for MOR upserts
(HUDI-6926), so the log
+ * records themselves do carry the column -- only the base-file records do
not.
+ */
+ private def writeMorDropPartitionColumnsCommits(): Unit = {
+ writeBatch(makeRows(1 to 30, ts = 1L, i => i * 10L),
+ DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL,
morDropPartitionColumnsOpts)
+ writeBatch(makeRows((1 to 30).filter(_ % 6 == 0), ts = 2L, i => i * 100L),
+ DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL,
morDropPartitionColumnsOpts)
+ }
+
+ /** Distinct `partition` values, rendering a null (the symptom under test)
as "null" so that the
+ * assertion reports it rather than failing while sorting. */
+ private def distinctPartitions(df: DataFrame): Seq[String] =
+ df.select("partition").distinct().collect().map(r =>
String.valueOf(r.get(0))).sorted.toSeq
+
private def fgReaderDf(extraOpts: Map[String, String] = Map.empty):
DataFrame =
spark.read.format("hudi")
.option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "true")
@@ -193,6 +223,16 @@ class TestLegacyParquetReadPath extends
HoodieSparkClientTestBase with ScalaAsse
spark.baseRelationToDataFrame(hadoopFsRelation)
}
+ /**
+ * Legacy MOR snapshot scan through [[MergeOnReadSnapshotRelation]] and
[[org.apache.hudi.HoodieMergeOnReadRDDV2]]:
+ * base-only splits take the skip-merging fast path, splits carrying log
files the file-group-reader branch.
+ */
+ private def legacyMorSnapshotDf(extraOpts: Map[String, String] = Map.empty):
DataFrame = {
+ val metaClient = createMetaClient(spark, basePath)
+ spark.baseRelationToDataFrame(
+ MergeOnReadSnapshotRelation(sqlContext, legacyReadOpts(extraOpts),
metaClient, None))
+ }
+
/**
* Whether the legacy parquet format engages its vectorized (batch) reader
for the table's
* schema. Because the schema carries a nested struct and an array, batch
support additionally
@@ -270,9 +310,7 @@ class TestLegacyParquetReadPath extends
HoodieSparkClientTestBase with ScalaAsse
assertSameRows(newReaderDf, legacyFileFormatDf(extractOpts))
assertSameRows(newReaderDf, legacyRelationDf(extractOpts))
- val partitions = legacyFileFormatDf(extractOpts)
-
.select("partition").distinct().collect().map(_.getString(0)).sorted.toSeq
- assertEquals(Seq("p0", "p1", "p2"), partitions)
+ assertEquals(Seq("p0", "p1", "p2"),
distinctPartitions(legacyFileFormatDf(extractOpts)))
}
@Test
@@ -470,4 +508,62 @@ class TestLegacyParquetReadPath extends
HoodieSparkClientTestBase with ScalaAsse
assertSameRows(newReaderDf, legacyDf)
}
}
+
+ @Test
+ def testMorSnapshotReadWithDroppedPartitionColumns(): Unit = {
+ writeMorDropPartitionColumnsCommits()
+
+ // The p0 file group carries a log file, so it is served by the
file-group-reader branch of
+ // HoodieMergeOnReadRDDV2, which sources every projected column from the
files -- and the p0 base
+ // file, holding the rows deltacommit 2 left alone, does not have the
partition column. The p1/p2
+ // groups stay base-only in the very same query, so the skip-merging fast
path -- the only one
+ // that ever appended the parsed values -- is pinned unregressed at the
same time.
+ val newReaderDf = fgReaderDf()
+ assertEquals(30, newReaderDf.count())
+ // Had the updates landed in a rewritten base file instead of a log, the
read-optimized query
+ // would already show them and no split in this table would take the
merging branch at all.
+ val readOptimizedDf = fgReaderDf(
+ Map(DataSourceReadOptions.QUERY_TYPE.key ->
DataSourceReadOptions.QUERY_TYPE_READ_OPTIMIZED_OPT_VAL))
+ assertEquals(0, readOptimizedDf.filter(col("ts") === 2L).count())
+
+ val legacyDf = legacyMorSnapshotDf()
+ assertSameRows(newReaderDf, legacyDf)
+ assertEquals(Seq("p0", "p1", "p2"), distinctPartitions(legacyDf))
+
+ // The p0 rows deltacommit 2 left alone are the ones served straight from
the base file, where the
+ // partition column does not exist; they must come back as p0 and not as
null.
+ val untouchedP0Ids = (1 to 30).filter(i => i % 3 == 0 && i % 6 !=
0).map(_.toString)
+ val baseServedP0Df = legacyDf.filter(col("ts") === 1L &&
col("id").isin(untouchedP0Ids: _*))
+ assertEquals(untouchedP0Ids.size.toLong, baseServedP0Df.count())
+ assertEquals(Seq("p0"), distinctPartitions(baseServedP0Df))
+ }
+
+ @Test
+ def testMorIncrementalReadWithDroppedPartitionColumnsOnLogOnlySlice(): Unit
= {
+ writeMorDropPartitionColumnsCommits()
+
+ val metaClient = createMetaClient(spark, basePath)
+ val firstInstant =
metaClient.getCommitsTimeline.filterCompletedInstants.firstInstant.get
+
+ // Start-exclusive span covering only the update deltacommit. Its
file-system view is built from
+ // the files that deltacommit touched -- log files alone -- so the slice
carries no base file and
+ // the split has to resolve its partition values off a log file's path
instead.
+ //
+ // NOTE: this test passes with or without the splicing, so unlike the
snapshot test above it is
+ // not a repro of the null-partition bug. The rows here come from log
records, and those DO carry
+ // the partition column: HUDI-6926 makes a MOR upsert ignore
drop.partition.columns. What it does
+ // pin is the log-only resolution itself -- reading the values off
HoodieLogFile#getPath, whose
+ // pathInfo is null for these log files -- and that the result agrees with
the file-group-reader
+ // oracle.
+ val incOpts = Map(
+ DataSourceReadOptions.QUERY_TYPE.key ->
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL,
+ DataSourceReadOptions.START_COMMIT.key -> firstInstant.getCompletionTime)
+
+ val newReaderDf = fgReaderDf(incOpts)
+ val legacyDf = spark.baseRelationToDataFrame(
+ MergeOnReadIncrementalRelationV2(sqlContext, legacyReadOpts(incOpts),
metaClient, None, RangeType.OPEN_CLOSED))
+
+ assertSameRows(newReaderDf, legacyDf)
+ assertEquals(Seq("p0"), distinctPartitions(legacyDf))
+ }
}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala
index ff0ce57d2b87..3c943037f2e9 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestStreamingSource.scala
@@ -17,9 +17,9 @@
package org.apache.hudi.functional
-import org.apache.hudi.DataSourceReadOptions
+import org.apache.hudi.{DataSourceReadOptions, HoodieSparkUtils}
import org.apache.hudi.DataSourceReadOptions.{START_OFFSET,
STREAMING_READ_TABLE_VERSION}
-import org.apache.hudi.DataSourceWriteOptions.{ORDERING_FIELDS,
RECORDKEY_FIELD}
+import org.apache.hudi.DataSourceWriteOptions.{ORDERING_FIELDS,
RECORDKEY_FIELD, TABLE_TYPE}
import org.apache.hudi.common.config.HoodieReaderConfig
import org.apache.hudi.common.model.HoodieTableType
import org.apache.hudi.common.model.HoodieTableType.{COPY_ON_WRITE,
MERGE_ON_READ}
@@ -30,7 +30,7 @@ import
org.apache.hudi.config.HoodieWriteConfig.{DELETE_PARALLELISM_VALUE, INSER
import org.apache.hudi.hadoop.fs.HadoopFSUtils
import org.apache.hudi.util.JavaConversions
-import org.apache.spark.sql.{Row, SaveMode}
+import org.apache.spark.sql.{DataFrame, Row, SaveMode}
import org.apache.spark.sql.streaming.StreamTest
import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
@@ -381,6 +381,105 @@ class TestStreamingSource extends StreamTest {
testLegacyIncrementalStreamSource(MERGE_ON_READ, HoodieTableVersion.EIGHT)
}
+ test("test mor stream source reads shredded variant with legacy file group
reader disabled") {
+ // #19578: with the file group reader disabled, MOR streaming batches
materialize through
+ // HoodieMergeOnReadRDDV2, the only user-facing path reading shredded
variant base files
+ // without a catalyst schema. The first stream covers the base-only split
(first batch, the
+ // branch this fix re-routes) and the log-only split (second batch); the
second stream covers
+ // the merged base + log split.
+ assume(HoodieSparkUtils.gteqSpark4_1, "Shredded variant base-file read
requires Spark 4.1 or higher")
+
+ withTempDir { inputDir =>
+ val tablePath =
s"${inputDir.getCanonicalPath}/test_mor_variant_legacy_stream"
+ HoodieTableMetaClient.newTableBuilder()
+ .setTableType(MERGE_ON_READ)
+ .setTableName(getTableName(tablePath))
+ .setRecordKeyFields("id")
+ .setOrderingFields("ts")
+
.initTable(HadoopFSUtils.getStorageConf(spark.sessionState.newHadoopConf()),
tablePath)
+
+ // INMEMORY index routes MOR inserts to log files, so the first base
file is the
+ // compaction's SHREDDED one; compact = true trips inline compaction on
that write.
+ def addVariantData(valuesSql: String, compact: Boolean): Unit = {
+ // The write must use the short name: it resolves to
Spark4DefaultSource, the only
+ // provider overriding CreatableRelationProvider.supportsDataType to
accept VariantType.
+ // The fully qualified "org.apache.hudi" resolves to DefaultSource
(short name "hudi_v1"),
+ // which has no override, so DataSource.planForWriting on Spark 4.x
rejects the variant
+ // column with UNSUPPORTED_DATA_TYPE_FOR_DATASOURCE.
+ spark.sql(valuesSql).write.format("hudi")
+ .options(commonOptions)
+ .option(TBL_NAME.key, getTableName(tablePath))
+ .option(TABLE_TYPE.key, MERGE_ON_READ.name)
+ .option("hoodie.index.type", "INMEMORY")
+ .option("hoodie.parquet.variant.write.shredding.enabled", "true")
+ .option("hoodie.parquet.variant.force.shredding.schema.for.test",
"key string")
+ .option(HoodieCompactionConfig.INLINE_COMPACT.key, compact.toString)
+ .option(HoodieCompactionConfig.INLINE_COMPACT_NUM_DELTA_COMMITS.key,
"2")
+ .mode(SaveMode.Append)
+ .save(tablePath)
+ }
+
+ // The read keeps the fully qualified name: it goes through
StreamSourceProvider, which
+ // never calls supportsDataType.
+ def variantStreamDf(): DataFrame = spark.readStream
+ .format("org.apache.hudi")
+ // force the legacy (non file-group-reader) incremental relation path
+ .option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "false")
+ .load(tablePath)
+ .selectExpr("id", "cast(v as string) as v", "ts")
+
+ // The legacy branch of getBatch materializes the micro batch from an
RDD via
+ // internalCreateDataFrame, so the physical plan is a "Scan
ExistingRDD"; this fails if the
+ // legacy branch is dropped and the source silently falls back to the
file group reader
+ // path, which scans a HadoopFsRelation ("FileScan" / "Scan parquet")
instead.
+ val assertLegacyRddPlan = AssertOnQuery { q =>
+ val plan = q.lastExecution.executedPlan.toString
+ assertTrue(plan.contains("Scan ExistingRDD"),
+ "expected the legacy RDD-backed incremental batch, but got plan: " +
plan)
+ assertTrue(!plan.contains("FileScan"),
+ "expected no file-group-reader HadoopFsRelation scan, but got plan:
" + plan)
+ true
+ }
+
+ addVariantData("""select 1 as id, parse_json('{"key":"v1"}') as v, 1000L
as ts""", compact = false)
+ addVariantData("""select 2 as id, parse_json('{"key":"v2"}') as v, 1000L
as ts""", compact = true)
+
+ testStream(variantStreamDf())(
+ // Base-only split: this batch spans both deltacommits and the
compaction commit, whose
+ // affected files resolve to the compacted shredded base file with no
log on top. This is
+ // the branch the fix re-routes to the file group reader.
+ AssertOnQuery { q => q.processAllAvailable(); true },
+ assertLegacyRddPlan,
+ CheckAnswerRows(Seq(Row(1, "{\"key\":\"v1\"}", 1000L), Row(2,
"{\"key\":\"v2\"}", 1000L)),
+ lastOnly = true, isSorted = false),
+ StopStream,
+
+ // Log-only split: MergeOnReadIncrementalRelationV2 builds its file
system view out of the
+ // span's affected files alone, and this span covers only the update
deltacommit, so the
+ // slice is the appended log file with no base file.
+ AssertOnQuery { _ =>
+ addVariantData("""select 1 as id, parse_json('{"key":"v1-updated"}')
as v, 1001L as ts""", compact = false)
+ true
+ },
+ StartStream(),
+ AssertOnQuery { q => q.processAllAvailable(); true },
+ CheckAnswerRows(Seq(Row(1, "{\"key\":\"v1-updated\"}", 1001L)),
lastOnly = true, isSorted = false)
+ )
+
+ // Merged split: a fresh testStream over a fresh streaming DataFrame
gets its own
+ // checkpoint, so it replays from the INIT offset and its first batch
spans the compaction
+ // commit and the update deltacommit together. Only such a span puts the
shredded base file
+ // and the update log file in one affected-file list, giving the base +
log slice that
+ // neither batch above produces.
+ testStream(variantStreamDf())(
+ AssertOnQuery { q => q.processAllAvailable(); true },
+ assertLegacyRddPlan,
+ CheckAnswerRows(Seq(Row(1, "{\"key\":\"v1-updated\"}", 1001L), Row(2,
"{\"key\":\"v2\"}", 1000L)),
+ lastOnly = true, isSorted = false)
+ )
+ }
+ }
+
private def testCheckpointTranslation(tableName: String,
tableType: HoodieTableType,
writeTableVersion: HoodieTableVersion,
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 8b270a097a3e..93392648107b 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
@@ -20,6 +20,7 @@
package org.apache.spark.sql.hudi.dml.schema
import org.apache.hudi.{DataSourceReadOptions, HoodieSparkUtils}
+import org.apache.hudi.common.model.HoodieRecord.HoodieRecordType
import org.apache.hudi.common.schema.HoodieSchema
import org.apache.hudi.common.schema.internal.HoodieSchemaException
import org.apache.hudi.common.testutils.HoodieTestUtils
@@ -285,8 +286,10 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
// 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.
+ // no-catalyst-schema legs are covered by the CDC round trip below and,
for
+ // streaming with hoodie.file.group.reader.enabled=false, by
+ // TestStreamingSource#"test mor stream source reads shredded variant
with legacy
+ // file group reader disabled".
val incRows = spark.read.format("hudi")
.option(DataSourceReadOptions.QUERY_TYPE.key,
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
.option(DataSourceReadOptions.START_COMMIT.key, "000")
@@ -448,6 +451,105 @@ class TestVariantDataType extends HoodieSparkSqlTestBase {
})
}
+ test("Test CDC captures VARIANT values from shredded and unshredded base
files") {
+ // #19578: CDC is the only default-config query path that builds the
internal reader
+ // context without a catalyst schema, and its BASE_FILE_INSERT case
additionally reads
+ // the new base file directly, bypassing the context, so it needs its own
full-variant
+ // rewrite. That rewrite is picked from the spark adapter and the
requested schema alone,
+ // never from the file, so the unshredded leg runs through it too; both
layouts are swept
+ // below so neither side of that branch goes uncovered.
+ assume(HoodieSparkUtils.gteqSpark4_1, "Shredded variant base-file read
requires Spark 4.1 or higher")
+
+ // OP_KEY_ONLY reconstructs both images by reading the file slices;
DATA_BEFORE_AFTER
+ // (the default) reads the update images from the cdc log instead. The
insert leg takes
+ // BASE_FILE_INSERT in both modes.
+ Seq(true, false).foreach { shredded =>
+ Seq("OP_KEY_ONLY", "DATA_BEFORE_AFTER").foreach { loggingMode =>
+ // SPARK is pinned instead of sweeping both record types: a
cdc-enabled table always
+ // writes through FileGroupReaderBasedMergeHandle, and the merger's
record type picks
+ // that handle's reader context. AVRO would route the update's
base-file read through
+ // HoodieAvroParquetReader, whose shredded read is the separate defect
tracked as
+ // #19567/#19582; SPARK routes it through
SparkFileFormatInternalRowReaderContext,
+ // fixed in #19558. The CDC read path under test is independent of
that choice.
+ withRecordType(Seq(HoodieRecordType.SPARK))(withTempDir { tmp =>
+ val leg = s"$loggingMode, shredded=$shredded"
+ val tableName = generateTableName
+ val tablePath = tmp.getCanonicalPath
+ // The forced shredding schema belongs to the shredded leg only; the
unshredded leg
+ // must reach the writer with shredding off and no forced schema.
+ val forceShreddingProp =
+ if (shredded)
"hoodie.parquet.variant.force.shredding.schema.for.test = 'key string'," else ""
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | v variant,
+ | ts long
+ |) using hudi
+ | location '$tablePath'
+ | tblproperties (
+ | primaryKey = 'id',
+ | type = 'cow',
+ | preCombineField = 'ts',
+ | 'hoodie.table.cdc.enabled' = 'true',
+ | 'hoodie.table.cdc.supplemental.logging.mode' =
'$loggingMode',
+ | hoodie.parquet.variant.write.shredding.enabled = '$shredded',
+ | $forceShreddingProp
+ | hoodie.index.type = 'INMEMORY'
+ | )
+ """.stripMargin)
+
+ spark.sql(s"""insert into $tableName values (1,
parse_json('{"key":"value1"}'), 1000)""")
+
+ // Pin the layout the CDC reads: without this, one leg silently
degrades into a
+ // second copy of the other and its half of the rewrite branch goes
uncovered.
+ val baseFiles = listDataParquetFiles(tablePath)
+ assert(baseFiles.nonEmpty, s"[$leg] should have a base parquet file
after the insert")
+ baseFiles.foreach { filePath =>
+ val parquetSchema = readParquetSchema(filePath)
+ val variantGroup = getFieldAsGroup(parquetSchema, "v")
+ if (shredded) {
+ assert(variantGroup.containsField("typed_value"),
+ s"[$leg] base file should carry typed_value.
Schema:\n$variantGroup")
+ } else {
+ assert(!variantGroup.containsField("typed_value"),
+ s"[$leg] base file must not carry typed_value.
Schema:\n$variantGroup")
+ }
+ }
+
+ spark.sql(s"""update $tableName set v =
parse_json('{"key":"value2"}'), ts = 1001 where id = 1""")
+
+ val cdc = spark.read.format("hudi")
+ .option(DataSourceReadOptions.QUERY_TYPE.key,
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
+ .option(DataSourceReadOptions.INCREMENTAL_FORMAT.key,
DataSourceReadOptions.INCREMENTAL_FORMAT_CDC_VAL)
+ .option(DataSourceReadOptions.START_COMMIT.key, "000")
+ .load(tablePath)
+
+ // Insert leg: the after-image comes from BASE_FILE_INSERT's direct
read of the
+ // base file; a null here means that read dropped the variant
payload.
+ val insertRows = cdc.where("op = 'i'")
+ .selectExpr("get_json_object(after, '$.v.key') as
after_key").collect()
+ assert(insertRows.length == 1, s"[$leg] expected exactly one insert
cdc row")
+ assert(insertRows(0).getString(0) == "value1",
+ s"[$leg] insert after-image lost the variant payload:
${insertRows(0)}")
+
+ // Update leg: images come from slice reads (OP_KEY_ONLY) or the cdc
log
+ // (DATA_BEFORE_AFTER); both must carry the variant payloads.
+ val updateRows = cdc.where("op = 'u'")
+ .selectExpr(
+ "get_json_object(before, '$.v.key') as before_key",
+ "get_json_object(after, '$.v.key') as after_key")
+ .collect()
+ assert(updateRows.length == 1, s"[$leg] expected exactly one update
cdc row")
+ assert(updateRows(0).getString(0) == "value1",
+ s"[$leg] update before-image lost the variant payload:
${updateRows(0)}")
+ assert(updateRows(0).getString(1) == "value2",
+ s"[$leg] update after-image lost the variant payload:
${updateRows(0)}")
+ })
+ }
+ }
+ }
+
test("Test bulk_insert row-writer round-trips VARIANT") {
assume(HoodieSparkUtils.gteqSpark4_0, "Variant type requires Spark 4.0 or
higher")