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 a4cde3a1d0bc test(spark): cover the legacy parquet read path with
file-group reader disabled (#19133)
a4cde3a1d0bc is described below
commit a4cde3a1d0bcba5c4a179edd0d0a78faa5f0f556
Author: Y Ethan Guo <[email protected]>
AuthorDate: Mon Jul 13 09:19:25 2026 -0700
test(spark): cover the legacy parquet read path with file-group reader
disabled (#19133)
* test(spark): cover the legacy parquet read path with file-group reader
disabled
* test(spark): exercise legacy parquet read path on nested struct/array
columns
Address review feedback on #19133:
- Add a nested struct and an array column so the legacy parquet reader is
driven through its complex-type branch (the vectorized nested-column path,
historically fragile per HUDI-7190/#10265), not just flat scalar columns.
- Sort compared rows by the named 'id' column instead of a positional index.
* test(spark): keep vectorized nested-column coverage on spark3.3
The nested struct/array columns flip the legacy parquet reader's
supportBatch off on spark3.3, where
spark.sql.parquet.enableNestedColumnVectorizedReader defaults to false
(true only from spark3.4). That made
testCowSnapshotReadEqualsFileGroupReader
and testCowSnapshotReadWithoutVectorizedReader both fall back to the
parquet-mr row path on 3.3, dropping the vectorized nested-column branch
the suite targets.
Enable nested-column vectorization in setUp so the vectorized branch runs
on every Spark profile, and assert supportBatch is true in the vectorized
case and false in the non-vectorized case so a Spark default change cannot
silently collapse the two paths again.
* test(spark): cover legacy reader implicit int->long type-change path
The prior cases all wrote and read the same schema, so typeChangeInfos
stayed empty and shouldUseInternalSchema never became true in the
SparkXXLegacyHoodieParquetFileFormat shims. The suite never reached the
branch that swaps Spark's stock VectorizedParquetRecordReader for Hudi's
HoodieVectorizedParquetRecordReader -- the Hudi-specific reason these
per-version copies exist.
Add testCowSnapshotReadWithImplicitTypeChange: commit 1 writes `value` as
int32; commit 2 upserts only the p0 rows with a widened long value too
large for an int, promoting the table schema to long. In COW the p1/p2
base files keep the narrower physical int, so reading them against the
long table schema goes through HoodieParquetFileFormatHelper
.buildImplicitSchemaChangeInfo and HoodieVectorizedParquetRecordReader.
Asserts the vectorized branch is engaged, the values widen correctly, and
the legacy path still matches the file-group reader.
* test(spark): cover schema-on-read and remaining type-change branches of
the legacy read path
Address review on the legacy-read-path suite:
- testCowSnapshotReadWithSchemaOnRead: the one production shape in which
DefaultSource#resolveBaseFileOnlyRelation returns BaseFileOnlyRelation
itself (buildScan ships), flips
shouldExtractPartitionValuesFromPartitionPath
off (shims built with shouldAppendPartitionValues = false), and drives the
explicit internal-schema branch (InternalSchemaCache +
InternalSchemaMerger).
The InternalSchema is seeded without DDL via hoodie.schema.on.read.enable
plus hoodie.datasource.write.reconcile.schema on the writes.
- testCowSnapshotReadWithImplicitTypeChangeWithoutVectorizedReader: the
row-based Cast/GenerateUnsafeProjection widening the shims run when the
vectorized reader is disabled.
- testCowSnapshotReadWithNestedTypeChange: int->long inside the nested
struct;
vectorized reads must fail fast with the documented
IllegalArgumentException
and the advertised workaround (vectorized reader off) must read the
promoted
struct correctly through the row-based Cast branch.
---------
Co-authored-by: voonhous <[email protected]>
---
.../functional/TestLegacyParquetReadPath.scala | 473 +++++++++++++++++++++
1 file changed, 473 insertions(+)
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
new file mode 100644
index 000000000000..fac97a0cbecb
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestLegacyParquetReadPath.scala
@@ -0,0 +1,473 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hudi.functional
+
+import org.apache.hudi.{BaseFileOnlyRelation, DataSourceReadOptions,
DataSourceWriteOptions, IncrementalRelationV1, IncrementalRelationV2,
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.testutils.HoodieSparkClientTestBase
+
+import org.apache.spark.sql.{DataFrame, Row, SaveMode, SparkSession}
+import org.apache.spark.sql.functions.{col, lit, struct}
+import org.apache.spark.sql.types.{IntegerType, LongType}
+import org.junit.jupiter.api.{AfterEach, BeforeEach, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
+
+/** Row shape written by these tests. A nested struct and an array are
included so the legacy
+ * parquet read path is exercised on complex types -- the historically fragile
vectorized
+ * nested-column branch (e.g. HUDI-7190), not just flat scalar columns. */
+private case class LegacyNested(a: Int, b: String)
+
+private case class LegacyTestRow(id: String,
+ ts: Long,
+ value: Long,
+ partition: String,
+ nested: LegacyNested,
+ tags: Seq[Int])
+
+/**
+ * Functional tests for the legacy (pre-file-group-reader) Spark read path:
+ * [[BaseFileOnlyRelation]], [[IncrementalRelationV1]],
[[IncrementalRelationV2]] and the
+ * per-Spark-version legacy Hudi parquet file format created via
+ * `sparkAdapter.createLegacyHoodieParquetFileFormat`.
+ *
+ * In the batch datasource, `DefaultSource` routes normal reads to the
file-group-reader-based
+ * relations regardless of `hoodie.file.group.reader.enabled`; the legacy
relations still run in
+ * production for metadata-table reads and for streaming reads with the flag
disabled. To exercise
+ * them functionally here, the legacy relations are constructed directly (with
the flag set to
+ * false in their options, matching how the streaming sources invoke them) and
their results are
+ * compared row-by-row against the file-group-reader-enabled reads of the same
table.
+ *
+ * `DefaultSource#resolveBaseFileOnlyRelation` returns
[[BaseFileOnlyRelation]] itself only under
+ * schema-on-read and converts it to a `HadoopFsRelation` otherwise, so the
relation's own
+ * `buildScan` ships only in the schema-on-read shape; both scan shapes are
exercised below.
+ */
+class TestLegacyParquetReadPath extends HoodieSparkClientTestBase with
ScalaAssertionSupport {
+
+ var spark: SparkSession = _
+
+ private val writeOpts = Map(
+ "hoodie.insert.shuffle.parallelism" -> "2",
+ "hoodie.upsert.shuffle.parallelism" -> "2",
+ DataSourceWriteOptions.RECORDKEY_FIELD.key -> "id",
+ DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "partition",
+ DataSourceWriteOptions.TABLE_TYPE.key ->
DataSourceWriteOptions.COW_TABLE_TYPE_OPT_VAL,
+ DataSourceWriteOptions.HIVE_STYLE_PARTITIONING.key -> "true",
+ HoodieTableConfig.ORDERING_FIELDS.key -> "ts",
+ HoodieWriteConfig.TBL_NAME.key -> "legacy_read_path_tbl"
+ )
+
+ // Columns compared across the read paths; meta fields are persisted in the
base files, so the
+ // record key and commit time must match exactly between the legacy and new
readers. `nested` and
+ // `tags` force the legacy parquet reader through its complex-type (struct /
array) branch.
+ private val comparedCols =
+ Seq("_hoodie_commit_time", "_hoodie_record_key", "id", "ts", "value",
"partition", "nested", "tags")
+
+ @BeforeEach override def setUp(): Unit = {
+ setTableName("legacy_read_path_tbl")
+ initPath()
+ initSparkContexts()
+ spark = sqlContext.sparkSession
+ // The test schema carries a nested struct and an array. On the legacy
parquet read path, batch
+ // (vectorized) support for such complex types is additionally gated on
nested-column
+ // vectorization, which defaults off on spark3.3 and on only from
spark3.4. Enable it so the
+ // vectorized nested-column branch -- the one HUDI-7190 fixed -- is
exercised on every Spark
+ // profile instead of silently falling back to parquet-mr on 3.3 (which
would make the vectorized
+ // and non-vectorized cases below collapse onto the same row-based path
there).
+ spark.conf.set("spark.sql.parquet.enableNestedColumnVectorizedReader",
"true")
+ initHoodieStorage()
+ }
+
+ @AfterEach override def tearDown(): Unit = {
+ cleanupResources()
+ spark = null
+ }
+
+ private def makeRows(ids: Seq[Int], ts: Long, valueFn: Int => Long):
Seq[LegacyTestRow] =
+ ids.map(i => LegacyTestRow(i.toString, ts, valueFn(i), "p" + (i % 3),
+ LegacyNested(i, "v" + valueFn(i)), Seq(i, ts.toInt)))
+
+ private def writeBatch(rows: Seq[LegacyTestRow], operation: String,
+ extraWriteOpts: Map[String, String] = Map.empty):
Unit = {
+ spark.createDataFrame(rows)
+ .write.format("hudi")
+ .options(writeOpts ++ extraWriteOpts)
+ .option(DataSourceWriteOptions.OPERATION.key, operation)
+ .mode(SaveMode.Append)
+ .save(basePath)
+ }
+
+ /** Commit 1: insert 30 rows; commit 2: upsert rows 1-10 with new values. */
+ private def writeTwoCommits(): Unit = {
+ writeBatch(makeRows(1 to 30, ts = 1L, i => i * 10L),
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL)
+ writeBatch(makeRows(1 to 10, ts = 2L, i => i * 100L),
DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
+ }
+
+ // > Int.MaxValue, so a widened value is representable only as a long
+ private val widenedBase = 10000000000L
+
+ /**
+ * Commit 1: insert ids 1..30 with `value` written as INT32; commit 2:
upsert only the p0 rows
+ * (id % 3 == 0) with a LONG `value` too large for an int, promoting the
table schema to long.
+ * In COW, commit 2 rewrites just the p0 file group, so the p1/p2 base files
keep the narrower
+ * physical int while the table schema is now long -- reading them exercises
the legacy format's
+ * type-change reconciliation. Updating a single partition is deliberate:
upserting across all
+ * partitions would rewrite every file group and leave no narrow base files
behind.
+ */
+ private def writeIntToLongCommits(extraWriteOpts: Map[String, String] =
Map.empty): Unit = {
+ spark.createDataFrame(makeRows(1 to 30, ts = 1L, i => i.toLong))
+ .withColumn("value", col("value").cast(IntegerType))
+ .write.format("hudi")
+ .options(writeOpts ++ extraWriteOpts)
+ .option(DataSourceWriteOptions.OPERATION.key,
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL)
+ .mode(SaveMode.Append)
+ .save(basePath)
+ writeBatch(makeRows((1 to 30).filter(_ % 3 == 0), ts = 2L, i =>
widenedBase + i),
+ DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL, extraWriteOpts)
+ }
+
+ /**
+ * Asserts the promoted `value` column of a [[writeIntToLongCommits]] table:
p1/p2 ids come from
+ * int base files widened on read, p0 ids carry the large long values
written in commit 2.
+ */
+ private def assertWidenedValues(df: DataFrame): Unit = {
+ assertEquals(LongType, df.schema("value").dataType,
+ "Reading the promoted table must surface `value` as long")
+ val actual = df.select("id", "value").collect()
+ .map(r => (r.getString(0), r.getLong(1))).toSeq.sortBy(_._1.toInt)
+ val expected = (1 to 30).map(i => (i.toString, if (i % 3 == 0) widenedBase
+ i else i.toLong))
+ assertEquals(expected, actual)
+ }
+
+ private def fgReaderDf(extraOpts: Map[String, String] = Map.empty):
DataFrame =
+ spark.read.format("hudi")
+ .option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "true")
+ .options(extraOpts)
+ .load(basePath)
+
+ private def legacyReadOpts(extraOpts: Map[String, String]): Map[String,
String] =
+ Map(
+ "path" -> basePath,
+ DataSourceReadOptions.QUERY_TYPE.key ->
DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL,
+ HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key -> "false"
+ ) ++ extraOpts
+
+ /**
+ * Legacy scan through [[BaseFileOnlyRelation]]'s own `PrunedFilteredScan`
implementation
+ * (`HoodieBaseRelation.buildScan` -> base-file readers built on the legacy
parquet format).
+ */
+ private def legacyRelationDf(extraOpts: Map[String, String] = Map.empty):
DataFrame = {
+ val metaClient = createMetaClient(spark, basePath)
+ spark.baseRelationToDataFrame(
+ BaseFileOnlyRelation(sqlContext, metaClient, legacyReadOpts(extraOpts),
None))
+ }
+
+ /**
+ * Legacy scan through the `HadoopFsRelation` conversion that
+ * `DefaultSource#resolveBaseFileOnlyRelation` applies, executing the
per-Spark-version
+ * legacy Hudi parquet file format inside a regular file-source scan.
+ */
+ private def legacyFileFormatDf(extraOpts: Map[String, String] = Map.empty):
DataFrame = {
+ val metaClient = createMetaClient(spark, basePath)
+ val hadoopFsRelation =
+ BaseFileOnlyRelation(sqlContext, metaClient, legacyReadOpts(extraOpts),
None).toHadoopFsRelation
+
assertTrue(hadoopFsRelation.fileFormat.getClass.getSimpleName.contains("LegacyHoodieParquetFileFormat"),
+ s"Expected the legacy parquet file format but got
${hadoopFsRelation.fileFormat.getClass.getName}")
+ spark.baseRelationToDataFrame(hadoopFsRelation)
+ }
+
+ /**
+ * 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
+ * requires nested-column vectorization; asserting on this pins which branch
of the reader a test
+ * exercises so the vectorized and row-based cases cannot silently collapse
onto one path (e.g. if
+ * a Spark default change dropped nested-column vectorization on some
profile).
+ */
+ private def legacyFormatSupportsBatch: Boolean = {
+ val metaClient = createMetaClient(spark, basePath)
+ val hadoopFsRelation =
+ BaseFileOnlyRelation(sqlContext, metaClient, legacyReadOpts(Map.empty),
None).toHadoopFsRelation
+ hadoopFsRelation.fileFormat.supportBatch(spark, hadoopFsRelation.schema)
+ }
+
+ private def collectSorted(df: DataFrame): Seq[Row] =
+ df.select(comparedCols.map(col):
_*).collect().toSeq.sortBy(_.getAs[String]("id").toInt)
+
+ private def assertSameRows(expected: DataFrame, actual: DataFrame): Unit = {
+ val expectedRows = collectSorted(expected)
+ assertTrue(expectedRows.nonEmpty, "Comparison must cover a non-empty
result")
+ assertEquals(expectedRows, collectSorted(actual))
+ }
+
+ @Test
+ def testCowSnapshotReadEqualsFileGroupReader(): Unit = {
+ writeTwoCommits()
+
+ // With nested-column vectorization enabled (see setUp), the legacy format
must take its
+ // vectorized branch on every profile; otherwise this would degenerate to
the same row-based
+ // path as testCowSnapshotReadWithoutVectorizedReader.
+ assertTrue(legacyFormatSupportsBatch,
+ "Legacy parquet format must engage the vectorized reader on the
nested-column schema")
+
+ val newReaderDf = fgReaderDf()
+ assertEquals(30, newReaderDf.count())
+
+ Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf =>
+ assertSameRows(newReaderDf, legacyDf)
+ // The upserts from the second commit must be visible through the legacy
path.
+ val updatedValues = legacyDf.filter(col("ts") === 2L)
+ .collect().map(_.getAs[Long]("value")).sorted.toSeq
+ assertEquals((1 to 10).map(_ * 100L), updatedValues)
+ }
+ }
+
+ @Test
+ def testCowSnapshotReadWithoutVectorizedReader(): Unit = {
+ writeTwoCommits()
+
+ val vectorizedKey = "spark.sql.parquet.enableVectorizedReader"
+ val previous = spark.conf.get(vectorizedKey, "true")
+ spark.conf.set(vectorizedKey, "false")
+ try {
+ // Row-based (non-batch) branch of the legacy parquet file format. Pin
that the disabled
+ // vectorized reader really forces the fallback path, so this case stays
distinct from the
+ // vectorized one above on every profile.
+ assertFalse(legacyFormatSupportsBatch,
+ "Disabling the vectorized reader must force the legacy parquet format
onto the row-based path")
+ val newReaderDf = fgReaderDf()
+ assertSameRows(newReaderDf, legacyFileFormatDf())
+ assertSameRows(newReaderDf, legacyRelationDf())
+ } finally {
+ spark.conf.set(vectorizedKey, previous)
+ }
+ }
+
+ @Test
+ def testCowSnapshotReadWithPartitionValuesExtractedFromPath(): Unit = {
+ writeTwoCommits()
+
+ // BaseFileOnlyRelation always appends partition values parsed from the
(hive-style)
+ // partition path; enabling the same extraction on the new reader must
yield equal rows.
+ val extractOpts =
Map(DataSourceReadOptions.EXTRACT_PARTITION_VALUES_FROM_PARTITION_PATH.key ->
"true")
+ val newReaderDf = fgReaderDf(extractOpts)
+ 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)
+ }
+
+ @Test
+ def testPartitionAndDataFilterPushdown(): Unit = {
+ writeTwoCommits()
+
+ def applyFilters(df: DataFrame): DataFrame =
+ df.filter(col("partition") === "p1" && col("value") > 100L)
+
+ // Partition p1 holds ids with id % 3 == 1: updated ids {4, 7, 10} have
value > 100
+ // (id 1 has value exactly 100) and untouched ids {13, 16, 19, 22, 25, 28}
do as well.
+ val newReaderDf = applyFilters(fgReaderDf())
+ assertEquals(9, newReaderDf.count())
+ assertSameRows(newReaderDf, applyFilters(legacyFileFormatDf()))
+ assertSameRows(newReaderDf, applyFilters(legacyRelationDf()))
+ }
+
+ @Test
+ def testCowIncrementalReadEqualsFileGroupReader(): Unit = {
+ writeTwoCommits()
+ writeBatch(makeRows(11 to 15, ts = 3L, i => i * 1000L),
DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
+
+ val metaClient = createMetaClient(spark, basePath)
+ val firstInstant =
metaClient.getCommitsTimeline.filterCompletedInstants.firstInstant.get
+
+ // New-reader incremental query; on current table versions the start bound
is a completion
+ // time and the range is start-exclusive, so this returns rows written by
commits 2 and 3.
+ val newReaderDf = spark.read.format("hudi")
+ .option(HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key, "true")
+ .option(DataSourceReadOptions.QUERY_TYPE.key,
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL)
+ .option(DataSourceReadOptions.START_COMMIT.key,
firstInstant.getCompletionTime)
+ .load(basePath)
+
+ // V1 slices the timeline by instant time, V2 by completion time; both are
start-exclusive.
+ val incOptsV1 = Map(
+ DataSourceReadOptions.QUERY_TYPE.key ->
DataSourceReadOptions.QUERY_TYPE_INCREMENTAL_OPT_VAL,
+ DataSourceReadOptions.START_COMMIT.key -> firstInstant.requestedTime,
+ HoodieReaderConfig.FILE_GROUP_READER_ENABLED.key -> "false")
+ val incOptsV2 = incOptsV1.updated(DataSourceReadOptions.START_COMMIT.key,
firstInstant.getCompletionTime)
+
+ val legacyV1Df = spark.baseRelationToDataFrame(
+ new IncrementalRelationV1(sqlContext, incOptsV1, None, metaClient))
+ val legacyV2Df = spark.baseRelationToDataFrame(
+ new IncrementalRelationV2(sqlContext, incOptsV2, None, metaClient,
RangeType.OPEN_CLOSED))
+
+ // COW upserts preserve the original commit time of untouched rows in
rewritten files, so the
+ // incremental result is exactly the rows written by commits 2 and 3.
+ val expected = ((1 to 10).map(i => (i.toString, i * 100L)) ++ (11 to
15).map(i => (i.toString, i * 1000L)))
+ .sortBy(_._1.toInt)
+ Seq(newReaderDf, legacyV1Df, legacyV2Df).foreach { df =>
+ val actual = df.select("id", "value").collect()
+ .map(r => (r.getString(0), r.getLong(1))).toSeq.sortBy(_._1.toInt)
+ assertEquals(expected, actual)
+ }
+
+ assertSameRows(newReaderDf, legacyV1Df)
+ assertSameRows(newReaderDf, legacyV2Df)
+ }
+
+ @Test
+ def testCowSnapshotReadWithImplicitTypeChange(): Unit = {
+ // The Hudi-specific reason these per-version parquet formats exist (vs
stock ParquetFileFormat)
+ // is on-read type reconciliation: when a base file's physical column type
is narrower than the
+ // table schema,
HoodieParquetFileFormatHelper.buildImplicitSchemaChangeInfo records the change
+ // and the vectorized read runs through Hudi's
HoodieVectorizedParquetRecordReader (which widens
+ // the column vector) instead of Spark's stock
VectorizedParquetRecordReader.
+ writeIntToLongCommits()
+
+ // Vectorization must be on so the read takes the
HoodieVectorizedParquetRecordReader branch
+ // rather than the row-based parquet-mr fallback (which reconciles types
via a different path).
+ assertTrue(legacyFormatSupportsBatch,
+ "Vectorized reader must be engaged so the implicit type change runs
through " +
+ "HoodieVectorizedParquetRecordReader")
+
+ val newReaderDf = fgReaderDf()
+ Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf =>
+ assertWidenedValues(legacyDf)
+ // The legacy path must still agree with the file-group reader on the
promoted column.
+ assertSameRows(newReaderDf, legacyDf)
+ }
+ }
+
+ @Test
+ def testCowSnapshotReadWithImplicitTypeChangeWithoutVectorizedReader(): Unit
= {
+ writeIntToLongCommits()
+
+ val vectorizedKey = "spark.sql.parquet.enableVectorizedReader"
+ val previous = spark.conf.get(vectorizedKey, "true")
+ spark.conf.set(vectorizedKey, "false")
+ try {
+ // With the vectorized reader off, the legacy format reconciles the type
change on its
+ // row-based branch instead: a Cast from the file's narrower type
compiled into a
+ // GenerateUnsafeProjection -- an implementation separate from
+ // HoodieVectorizedParquetRecordReader, so it needs its own coverage.
+ assertFalse(legacyFormatSupportsBatch,
+ "Disabling the vectorized reader must force the legacy parquet format
onto the row-based path")
+ val newReaderDf = fgReaderDf()
+ Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf =>
+ assertWidenedValues(legacyDf)
+ assertSameRows(newReaderDf, legacyDf)
+ }
+ } finally {
+ spark.conf.set(vectorizedKey, previous)
+ }
+ }
+
+ @Test
+ def testCowSnapshotReadWithNestedTypeChange(): Unit = {
+ // Same int->long promotion, but inside the `nested` struct. The changed
top-level column is
+ // then non-atomic, which the legacy format cannot reconcile in vectorized
mode: it must fail
+ // fast with the documented IllegalArgumentException instead of returning
corrupt columns, and
+ // the workaround the exception advertises (disabling the vectorized
reader) must actually read
+ // the promoted struct correctly through the row-based Cast branch.
+ writeBatch(makeRows(1 to 30, ts = 1L, i => i * 10L),
DataSourceWriteOptions.INSERT_OPERATION_OPT_VAL)
+ spark.createDataFrame(makeRows((1 to 30).filter(_ % 3 == 0), ts = 2L, i =>
i * 100L))
+ .withColumn("nested",
+ struct((col("nested.a") + lit(widenedBase)).as("a"),
col("nested.b").as("b")))
+ .write.format("hudi")
+ .options(writeOpts)
+ .option(DataSourceWriteOptions.OPERATION.key,
DataSourceWriteOptions.UPSERT_OPERATION_OPT_VAL)
+ .mode(SaveMode.Append)
+ .save(basePath)
+
+ assertTrue(legacyFormatSupportsBatch,
+ "Vectorized reader must be engaged so the non-atomic type change hits
the legacy format's rejection")
+ Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf =>
+ val thrown = assertThrows(classOf[Throwable]) {
+ legacyDf.collect()
+ }
+ val causes = Iterator.iterate(thrown: Throwable)(_.getCause).takeWhile(_
!= null).take(10).toSeq
+ assertTrue(causes.exists(c => c.isInstanceOf[IllegalArgumentException]
+ && String.valueOf(c.getMessage).contains("cannot be read in vectorized
mode")),
+ s"Expected the non-atomic type-change rejection but got: $thrown")
+ }
+
+ val vectorizedKey = "spark.sql.parquet.enableVectorizedReader"
+ val previous = spark.conf.get(vectorizedKey, "true")
+ spark.conf.set(vectorizedKey, "false")
+ try {
+ assertFalse(legacyFormatSupportsBatch,
+ "Disabling the vectorized reader must force the legacy parquet format
onto the row-based path")
+ val newReaderDf = fgReaderDf()
+ Seq(legacyRelationDf(), legacyFileFormatDf()).foreach { legacyDf =>
+ val actual = legacyDf.select("id", "nested").collect()
+ .map(r => (r.getString(0),
r.getStruct(1).getLong(0))).toSeq.sortBy(_._1.toInt)
+ val expected = (1 to 30).map(i => (i.toString, if (i % 3 == 0)
widenedBase + i else i.toLong))
+ assertEquals(expected, actual)
+ assertSameRows(newReaderDf, legacyDf)
+ }
+ } finally {
+ spark.conf.set(vectorizedKey, previous)
+ }
+ }
+
+ @Test
+ def testCowSnapshotReadWithSchemaOnRead(): Unit = {
+ // Schema-on-read is the one production shape in which
DefaultSource#resolveBaseFileOnlyRelation
+ // returns BaseFileOnlyRelation itself instead of converting it to a
HadoopFsRelation, making
+ // the relation's own buildScan the shipped scan path. It also flips
+ // BaseFileOnlyRelation.shouldExtractPartitionValuesFromPartitionPath
(defined as
+ // internalSchemaOpt.isEmpty) to false -- the only way the legacy parquet
format is constructed
+ // with shouldAppendPartitionValues = false -- and drives the format's
explicit internal-schema
+ // branch (InternalSchemaCache lookup + InternalSchemaMerger) instead of
the implicit
+ // footer-based reconciliation.
+ //
+ // No ALTER TABLE is needed to get an InternalSchema into commit metadata:
with
+ // hoodie.schema.on.read.enable plus
hoodie.datasource.write.reconcile.schema on the writes,
+ // HoodieSparkSqlWriter seeds the internal schema on the first commit and
evolves it with the
+ // int->long promotion on the second.
+ writeIntToLongCommits(Map(
+ DataSourceReadOptions.SCHEMA_EVOLUTION_ENABLED.key -> "true",
+ DataSourceWriteOptions.RECONCILE_SCHEMA.key -> "true"))
+
+ val readOpts = Map(DataSourceReadOptions.SCHEMA_EVOLUTION_ENABLED.key ->
"true")
+ val metaClient = createMetaClient(spark, basePath)
+ val schemaOnReadRelation = BaseFileOnlyRelation(sqlContext, metaClient,
legacyReadOpts(readOpts), None)
+ assertTrue(schemaOnReadRelation.hasSchemaOnRead,
+ "The writes must have recorded an InternalSchema in commit metadata for
schema-on-read to engage")
+ // Pin the flipped partition-values branch: under schema-on-read the
relation reads partition
+ // columns from the data files (empty partition schema) instead of
re-appending them from the
+ // partition path, unlike every other case in this suite.
+ assertTrue(schemaOnReadRelation.toHadoopFsRelation.partitionSchema.isEmpty,
+ "Schema-on-read must flip shouldExtractPartitionValuesFromPartitionPath
off")
+ assertTrue(BaseFileOnlyRelation(sqlContext, metaClient,
legacyReadOpts(Map.empty), None)
+ .toHadoopFsRelation.partitionSchema.nonEmpty,
+ "Without schema-on-read the converted relation appends partition values
from the path")
+
+ assertTrue(legacyFormatSupportsBatch,
+ "Vectorized reader must be engaged so the explicit type change runs
through " +
+ "HoodieVectorizedParquetRecordReader")
+
+ val newReaderDf = fgReaderDf(readOpts)
+ Seq(legacyRelationDf(readOpts), legacyFileFormatDf(readOpts)).foreach {
legacyDf =>
+ assertWidenedValues(legacyDf)
+ assertSameRows(newReaderDf, legacyDf)
+ }
+ }
+}