This is an automated email from the ASF dual-hosted git repository.
codope 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 e9da226db3a9 fix(spark): decide vectorized read per scan in
HoodieFileGroupReaderBasedFileFormat (#20183)
e9da226db3a9 is described below
commit e9da226db3a90747cc51d13fec56ebd1704c1699
Author: Sumit Singh <[email protected]>
AuthorDate: Tue Oct 13 17:36:08 2026 +0530
fix(spark): decide vectorized read per scan in
HoodieFileGroupReaderBasedFileFormat (#20183)
* fix(spark): decide vectorized read per scan in
HoodieFileGroupReaderBasedFileFormat
Spark caches the resolved relation per session, so one file format
instance serves every statement on a table, and supportBatch is only called for
scans narrower than spark.sql.codegen.maxFields. The vectorized decision was
kept in a mutable field and written into
spark.sql.parquet.enableVectorizedReader, so a wide scan could inherit a narrow
scan's answer and fail with ClassCastException: ColumnarBatchRow cannot be cast
to UnsafeRow. Evaluate canReadVectorized against each scan's ow [...]
* Fix new bug which got surface due to fix.
* Fix comments
* Fix comments
* test(spark): check fallback scans keep the session vectorized reader conf
Carry the two regression tests from #20193 into this change. The new
TestVectorizedReadDecisionPerScan checks the session conf after wide,
narrow, Vortex and timestamp-millis scans, but never after a scan that
falls back to row-based reads for a variant column or an incremental
query. Before this fix, buildReaderWithPartitionValues wrote that
fallback into spark.sql.parquet.enableVectorizedReader, so every later
read in the session lost vectorization.
- TestVariantShreddingMixedLayouts: a variant read on Spark 4.1+ falls
back to row-based reads and must leave the session conf at true.
- TestIncrementalReadWithFileGroupReader: a COW incremental read, which
is never vectorized, must leave the session conf at true. It saves
and restores one key instead of a one-entry list, and also checks
that the read returns the rows written.
---
.../HoodieVectorizedParquetRecordReader.java | 8 +-
.../HoodieFileGroupReaderBasedFileFormat.scala | 72 +++--
.../parquet/ParquetSchemaEvolutionUtils.scala | 13 +-
.../parquet/TestParquetSchemaEvolutionUtils.scala | 26 ++
.../TestIncrementalReadWithFileGroupReader.scala | 28 ++
.../TestNestedSchemaPruningOptimization.scala | 11 +-
.../common/TestVectorizedReadDecisionPerScan.scala | 323 +++++++++++++++++++++
.../schema/TestVariantShreddingMixedLayouts.scala | 34 ++-
8 files changed, 463 insertions(+), 52 deletions(-)
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/spark/sql/execution/datasources/parquet/HoodieVectorizedParquetRecordReader.java
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/spark/sql/execution/datasources/parquet/HoodieVectorizedParquetRecordReader.java
index fa944ccde6de..e086aaec0f70 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/spark/sql/execution/datasources/parquet/HoodieVectorizedParquetRecordReader.java
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/java/org/apache/spark/sql/execution/datasources/parquet/HoodieVectorizedParquetRecordReader.java
@@ -94,8 +94,12 @@ public class HoodieVectorizedParquetRecordReader extends
VectorizedParquetRecord
@Override
public void close() throws IOException {
super.close();
- for (Map.Entry<Integer, WritableColumnVector> e :
idToColumnVectors.entrySet()) {
- e.getValue().close();
+ // initBatch allocates these vectors. When initialize or initBatch throws,
Spark closes the
+ // reader before they exist, so skip them rather than mask the original
failure with an NPE.
+ if (idToColumnVectors != null) {
+ for (Map.Entry<Integer, WritableColumnVector> e :
idToColumnVectors.entrySet()) {
+ e.getValue().close();
+ }
}
idToColumnVectors = null;
columnarBatch = null;
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
index 6849798d259e..d0d503792e69 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderBasedFileFormat.scala
@@ -120,16 +120,6 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
private def evaluateRequiredFiltersPerRow: Boolean =
requiredFilters.nonEmpty && !requiredFiltersAppliedInPlan
- /**
- * Flag saying whether vectorized reading is supported.
- */
- private var supportVectorizedRead = false
-
- /**
- * Flag saying whether batch output is supported.
- */
- private var supportReturningBatch = false
-
/**
* Cached result of vector column detection keyed by schema identity.
* Avoids re-parsing metadata on repeated supportBatch calls with the same
schema.
@@ -166,30 +156,23 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
}
/**
- * Checks if the file format supports vectorized reading, please refer to
SPARK-40918.
- *
- * NOTE: for mor read, even for file-slice with only base file, we can read
parquet file with vectorized read,
- * but the return result of the whole data-source-scan phase cannot be batch,
- * because when there are any log file in a file slice, it needs to be read
by the file group reader.
- * Since we are currently performing merges based on rows, the result
returned by merging should be based on rows,
- * we cannot assume that all file slices have only base files.
- * So we need to set the batch result back to false.
+ * Whether the base-file reader may use a vectorized reader for a scan whose
output schema is `schema`.
*
+ * This is a pure function of the session conf, the schema and the table
properties. No state is kept
+ * between calls on purpose: Spark only calls [[supportBatch]] for scans
whose schema is below
+ * spark.sql.codegen.maxFields, so [[buildReaderWithPartitionValues]] must
evaluate this itself for
+ * the schema it is handed rather than reuse the answer given for some
earlier scan.
*/
- override def supportBatch(sparkSession: SparkSession, schema: StructType):
Boolean = {
+ private def canReadVectorized(sparkSession: SparkSession, schema:
StructType): Boolean = {
// Vector columns are stored as FIXED_LEN_BYTE_ARRAY in Parquet but read
as ArrayType in Spark.
// The binary→array conversion requires row-level access, so disable
vectorized batch reading.
if (detectVectorColumnsCached(schema).nonEmpty) {
- supportVectorizedRead = false
- supportReturningBatch = false
false
} else if (schema.fields.exists(f => containsType(f.dataType,
isVariantProjection))) {
// Spark 4.1's PushVariantIntoScan rewrites a variant column to a struct
of pushed-down
// extractions. The Spark vectorized parquet reader treats this as a
nested type change
// (data column is VariantType, required is a struct) and refuses to
read in vectorized
// mode (ParquetSchemaEvolutionUtils throws). Force row-based reading on
this path.
- supportVectorizedRead = false
- supportReturningBatch = false
false
} else if (HoodieSparkUtils.gteqSpark4_1
&& schema.fields.exists(f => containsType(f.dataType,
sparkAdapter.isVariantType))) {
@@ -206,8 +189,6 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
// carried inside a struct is exposed exactly the same way under any
range-partitioned query.
// The cost is that nested-variant tables lose vectorization on 4.1+ by
default, exactly as
// top-level ones do.
- supportVectorizedRead = false
- supportReturningBatch = false
false
} else {
val conf = sparkSession.sessionState.conf
@@ -217,8 +198,10 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
s.dataType,
sparkSession.sessionState.conf.orcVectorizedReaderNestedColumnEnabled))
// TODO: Implement columnar batch reading
https://github.com/apache/hudi/issues/17736
val lanceBatchSupported = false
+ // The Vortex reader returns rows only
+ val vortexBatchSupported = false
- val supportBatch = if (isMultipleBaseFileFormatsEnabled) {
+ val formatSupportsBatch = if (isMultipleBaseFileFormatsEnabled) {
parquetBatchSupported && orcBatchSupported
} else if (hoodieFileFormat == HoodieFileFormat.PARQUET) {
parquetBatchSupported
@@ -226,6 +209,8 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
orcBatchSupported
} else if (hoodieFileFormat == HoodieFileFormat.LANCE) {
lanceBatchSupported
+ } else if (hoodieFileFormat == HoodieFileFormat.VORTEX) {
+ vortexBatchSupported
} else {
throw new HoodieNotSupportedException("Unsupported file format: " +
hoodieFileFormat)
}
@@ -234,17 +219,27 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
// the same reader, so vectorized reading must be disabled for both. All
other combinations
// (COW snapshot, COW incremental, MOR snapshot) either have no log
merging or handle it via
// a separate non-vectorized fileGroupBaseFileReader while the base file
reader stays vectorized.
- supportVectorizedRead = !(isIncremental && (isMOR || isCDC)) &&
!isBootstrap && supportBatch
- // Required filters (the commit-time range of an incremental query) must
hold for every row, but the
- // vectorized reader only uses them to prune row groups. Unless the plan
already filters on them, return
- // rows rather than batches so that the base file read can evaluate them
row by row; decoding stays vectorized.
- supportReturningBatch = !isMOR && supportVectorizedRead &&
!evaluateRequiredFiltersPerRow
- logDebug(s"supportReturningBatch: $supportReturningBatch,
supportVectorizedRead: $supportVectorizedRead, isIncremental: $isIncremental, "
+
- s"isCDC: $isCDC, isBootstrap: $isBootstrap, superSupportBatch:
$supportBatch")
- supportReturningBatch
+ !(isIncremental && (isMOR || isCDC)) && !isBootstrap &&
formatSupportsBatch
}
}
+ /**
+ * Checks if the file format supports returning columnar batches, please
refer to SPARK-40918.
+ *
+ * NOTE: for MOR, a file slice with only a base file can still be read with
the vectorized reader,
+ * but the scan as a whole cannot return batches because slices with log
files are merged row by row.
+ */
+ override def supportBatch(sparkSession: SparkSession, schema: StructType):
Boolean = {
+ val vectorized = canReadVectorized(sparkSession, schema)
+ // Required filters (the commit-time range of an incremental query) must
hold for every row, but the
+ // vectorized reader only uses them to prune row groups. Unless the plan
already filters on them, return
+ // rows rather than batches so that the base file read can evaluate them
row by row; decoding stays vectorized.
+ val returningBatch = !isMOR && vectorized && !evaluateRequiredFiltersPerRow
+ logDebug(s"supportReturningBatch: $returningBatch, supportVectorizedRead:
$vectorized, " +
+ s"isIncremental: $isIncremental, isCDC: $isCDC, isBootstrap:
$isBootstrap, isMOR: $isMOR")
+ returningBatch
+ }
+
//for partition columns that we read from the file, we don't want them to be
constant column vectors so we
//modify the vector types in this scenario
override def vectorTypes(requiredSchema: StructType,
@@ -362,10 +357,13 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
val dataStructTypeWithMandatoryPartitionFields =
StructType(dataStructType.fields ++ partitionSchema.fields.filter(f =>
mandatoryFields.contains(f.name) && !isNestedPartitionField(f.name)))
val dataSchema = HoodieSchemaUtils.pruneDataSchema(schema,
HoodieSchemaConversionUtils.convertStructTypeToHoodieSchema(dataStructTypeWithMandatoryPartitionFields,
sanitizedTableName), exclusionFields)
-
spark.sessionState.conf.setConfString("spark.sql.parquet.enableVectorizedReader",
supportVectorizedRead.toString)
+ // Decide per scan: Spark skips supportBatch for scans wider than
spark.sql.codegen.maxFields.
+ // Never write it to spark.sql.parquet.enableVectorizedReader: Spark uses
that conf to decide whether to
+ // convert the scan output to UnsafeRow, and skipping the conversion for
vectorized rows fails.
+ val vectorizedRead = canReadVectorized(spark, outputSchema)
- val baseFileReader =
spark.sparkContext.broadcast(buildBaseFileReader(spark, options,
augmentedStorageConf.unwrap(), dataStructType, supportVectorizedRead))
- val fileGroupBaseFileReader = if (isMOR && supportVectorizedRead) {
+ val baseFileReader =
spark.sparkContext.broadcast(buildBaseFileReader(spark, options,
augmentedStorageConf.unwrap(), dataStructType, vectorizedRead))
+ val fileGroupBaseFileReader = if (isMOR && vectorizedRead) {
// for file group reader to perform read, we always need to read the
record without vectorized reader because our merging is based on row level.
// TODO: please consider to support vectorized reader in file group
reader
spark.sparkContext.broadcast(buildBaseFileReader(spark, options,
augmentedStorageConf.unwrap(), dataStructType, enableVectorizedRead = false))
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
index 47ee1c02cda9..dd462a3b529f 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetSchemaEvolutionUtils.scala
@@ -64,7 +64,10 @@ class ParquetSchemaEvolutionUtils(readConf: Configuration,
// Fetch internal schema
private lazy val querySchemaOption: util.Option[InternalSchema] =
pruneInternalSchema(internalSchemaOpt, requiredSchema)
- var shouldUseInternalSchema: Boolean = querySchemaOption.isPresent &&
tablePath != null
+ // Empty projections (count(*), select 1) read no column data, so skip
schema-on-read. Their query
+ // schema is unpruned (see pruneInternalSchema), and merging it would
request every column, including
+ // a variant as a plain struct that Spark 4.x's vectorized reader rejects.
+ var shouldUseInternalSchema: Boolean = requiredSchema.nonEmpty &&
querySchemaOption.isPresent && tablePath != null
private lazy val schemaUtils: HoodieSchemaUtils = sparkAdapter.getSchemaUtils
@@ -107,12 +110,8 @@ class ParquetSchemaEvolutionUtils(readConf: Configuration,
def setRequestedSchema(schema: StructType): Unit =
fileOwnConf().set(ParquetReadSupport.SPARK_ROW_REQUESTED_SCHEMA,
schema.json)
typeChangeInfos = if (shouldUseInternalSchema) {
- // Empty projections (count(*), select 1) read no column data, so there
is nothing to
- // reconstruct - and querySchemaOption is the UNPRUNED table schema in
that case (see
- // pruneInternalSchema), so running the guard would fail queries that
work fine.
- if (requiredSchema.nonEmpty) {
- ParquetSchemaEvolutionUtils.validateNoShreddedVariants(requiredSchema,
querySchemaOption.get(), footerFileMetaData)
- }
+ // shouldUseInternalSchema implies a non-empty projection, so
querySchemaOption is pruned here.
+ ParquetSchemaEvolutionUtils.validateNoShreddedVariants(requiredSchema,
querySchemaOption.get(), footerFileMetaData)
val mergedInternalSchema = new InternalSchemaMerger(fileSchema,
querySchemaOption.get(), true, true).mergeSchema()
val mergedSchema =
SparkInternalSchemaConverter.constructSparkSchemaFromInternalSchema(mergedInternalSchema)
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
index 87b0f5f3b59e..4e99d80fb6d0 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestParquetSchemaEvolutionUtils.scala
@@ -19,12 +19,14 @@
package org.apache.spark.sql.execution.datasources.parquet
+import org.apache.hudi.client.utils.SparkInternalSchemaConverter
import org.apache.hudi.common.schema.{HoodieSchema, HoodieSchemaField,
HoodieSchemaType}
import org.apache.hudi.common.schema.internal.{InternalSchema, Type =>
InternalType, Types => InternalTypes}
import org.apache.hudi.common.schema.internal.action.TableChanges
import org.apache.hudi.common.schema.internal.convert.InternalSchemaConverter
import org.apache.hudi.common.schema.internal.utils.SchemaChangeUtils
import org.apache.hudi.common.util
+import org.apache.hudi.common.util.{Option => HOption}
import org.apache.hudi.exception.HoodieException
import org.apache.hadoop.conf.Configuration
@@ -517,6 +519,30 @@ class TestParquetSchemaEvolutionUtils {
Assertions.assertNull(pushedFilterOf(readConf))
}
+ /**
+ * An empty projection (count(*), select 1, a partition-only select) reads
no column data, so it
+ * must bypass the schema-on-read merge. Its query schema is the UNPRUNED
table schema, and merging
+ * that would request every column - a variant one as the plain struct the
internal-schema
+ * converter emits, which Spark 4.x's vectorized reader rejects for a
VARIANT-annotated group.
+ */
+ @Test
+ def testEmptyProjectionBypassesInternalSchema(): Unit = {
+ val tablePath = "/tmp/hudi_table"
+ val conf = new Configuration()
+ conf.set(SparkInternalSchemaConverter.HOODIE_TABLE_PATH, tablePath)
+ val internalSchema = internalSchemaOf(
+ "id" -> HoodieSchema.create(HoodieSchemaType.INT),
+ "v" -> HoodieSchema.createVariant())
+ def utilsFor(requiredSchema: StructType): ParquetSchemaEvolutionUtils =
+ new ParquetSchemaEvolutionUtils(conf, new
Path(s"$tablePath/f1_0-1-1_20260101000000000.parquet"),
+ requiredSchema, new StructType(), HOption.of(internalSchema))
+
+ Assertions.assertFalse(utilsFor(new StructType()).shouldUseInternalSchema,
+ "An empty projection must be read without the internal schema")
+ Assertions.assertTrue(utilsFor(new StructType().add("id",
IntegerType)).shouldUseInternalSchema,
+ "A non-empty projection on a table with an internal schema must still
use it")
+ }
+
/** An internal schema over the given top-level columns, in order; field ids
are positional. */
private def internalSchemaOf(fields: (String, HoodieSchema)*):
InternalSchema =
InternalSchemaConverter.convert(HoodieSchema.createRecord("query",
"org.apache.hudi.test", null,
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
index b8c9e9652d1f..5eff5bd932d6 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/hudi/functional/TestIncrementalReadWithFileGroupReader.scala
@@ -31,6 +31,7 @@ import
org.apache.hudi.testutils.SparkClientFunctionalTestHarness
import org.apache.hadoop.fs.Path
import org.apache.spark.sql.{DataFrame, SaveMode}
import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
+import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.{CsvSource, ValueSource}
@@ -309,6 +310,33 @@ class TestIncrementalReadWithFileGroupReader extends
SparkClientFunctionalTestHa
Set(("k1", 5), ("k2", 4), ("k3", 5), ("k4", 5)))
}
+ // MOR incremental scans never read vectorized. That decision belongs to the
scan alone: it used to be
+ // written into spark.sql.parquet.enableVectorizedReader, so every later
read in the session lost
+ // vectorization.
+ @Test
+ def testMorIncrementalReadDoesNotMutateVectorizedReaderSessionConf(): Unit =
{
+ val vectorizedReaderConf = "spark.sql.parquet.enableVectorizedReader"
+ val previous = spark.conf.getOption(vectorizedReaderConf)
+ try {
+ spark.conf.set(vectorizedReaderConf, "true")
+ write(batches.head._1, "MERGE_ON_READ", 8, batches.head._2,
SaveMode.Overwrite)
+
+ val metaClient = HoodieTableMetaClient.builder()
+ .setConf(storageConf().newInstance()).setBasePath(basePath()).build()
+ val instant =
metaClient.getActiveTimeline.getCommitsTimeline.filterCompletedInstants
+ .lastInstant().get().getCompletionTime
+
+ assertEquals(batches.head._1.size, readIncremental(8, "000",
instant).collect().length)
+ assertTrue(spark.conf.get(vectorizedReaderConf).toBoolean,
+ "MOR incremental read must preserve the session vectorized-reader
setting")
+ } finally {
+ previous match {
+ case Some(value) => spark.conf.set(vectorizedReaderConf, value)
+ case None => spark.conf.unset(vectorizedReaderConf)
+ }
+ }
+ }
+
private def assertFullTableScanRange(metaClient: HoodieTableMetaClient,
useLegacyRdd: Boolean,
start: String,
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestNestedSchemaPruningOptimization.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestNestedSchemaPruningOptimization.scala
index f4b539de252d..6bcc84ee69d0 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestNestedSchemaPruningOptimization.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestNestedSchemaPruningOptimization.scala
@@ -344,7 +344,10 @@ class TestNestedSchemaPruningOptimization extends
HoodieSparkSqlTestBase {
test("Nested struct predicate pushdown with schema on read and a renamed
nested column") {
withTempDir { tmp =>
Seq("cow", "mor").foreach { tableType =>
- withSQLConf("hoodie.schema.on.read.enable" -> "true") {
+ // NOTE: Row-based reads, since the vectorized reader cannot read a
nested type change: the base file
+ // keeps the old layout of item, so
ParquetSchemaEvolutionUtils.getFileReadConf rejects the read
+ withSQLConf("hoodie.schema.on.read.enable" -> "true",
+ SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
val tableName = generateTableName
val tablePath = s"${tmp.getCanonicalPath}/$tableName"
@@ -373,9 +376,11 @@ class TestNestedSchemaPruningOptimization extends
HoodieSparkSqlTestBase {
test("Nested struct predicate pushdown with schema on read and quoted column
names") {
withTempDir { tmp =>
Seq("cow", "mor").foreach { tableType =>
- // NOTE: A top-level rename fails the incoming-schema check unless
column drops are allowed
+ // NOTE: A top-level rename fails the incoming-schema check unless
column drops are allowed. The nested
+ // rename needs row-based reads, as in the renamed nested column
test above.
withSQLConf("hoodie.schema.on.read.enable" -> "true",
- "hoodie.datasource.write.schema.allow.auto.evolution.column.drop" ->
"true") {
+ "hoodie.datasource.write.schema.allow.auto.evolution.column.drop" ->
"true",
+ SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
val tableName = generateTableName
val tablePath = s"${tmp.getCanonicalPath}/$tableName"
// Names that are not plain identifiers reach the reader
backtick-quoted (`名字`,
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestVectorizedReadDecisionPerScan.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestVectorizedReadDecisionPerScan.scala
new file mode 100644
index 000000000000..57848b89f66a
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestVectorizedReadDecisionPerScan.scala
@@ -0,0 +1,323 @@
+/*
+ * 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.spark.sql.hudi.common
+
+import org.apache.hudi.{DefaultSparkRecordMerger, HoodieSparkUtils,
SparkAdapterSupport}
+import org.apache.hudi.client.SparkRDDWriteClient
+import org.apache.hudi.client.common.HoodieSparkEngineContext
+import org.apache.hudi.common.config.{HoodieMetadataConfig, RecordMergeMode}
+import org.apache.hudi.common.model.{HoodieAvroIndexedRecord, HoodieKey,
HoodieRecord, HoodieTableType}
+import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
+import org.apache.hudi.config.{HoodieIndexConfig, HoodieWriteConfig}
+import org.apache.hudi.hadoop.fs.HadoopFSUtils
+import org.apache.hudi.index.HoodieIndex
+
+import org.apache.avro.{LogicalTypes, Schema, SchemaBuilder}
+import org.apache.avro.generic.{GenericData, IndexedRecord}
+import org.apache.hadoop.conf.Configuration
+import org.apache.spark.api.java.JavaSparkContext
+import org.apache.spark.sql.Row
+import org.apache.spark.sql.execution.{FileSourceScanExec,
WholeStageCodegenExec}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+
+import java.io.File
+import java.nio.file.{Files, Paths}
+import java.sql.Timestamp
+
+import scala.collection.JavaConverters._
+
+/**
+ * Spark caches the resolved relation of a table per session, so every
statement on the table shares one
+ * HoodieFileGroupReaderBasedFileFormat instance, and it asks that instance
about batch support only for scans
+ * narrower than spark.sql.codegen.maxFields. A wider scan therefore has to
decide about vectorized reading for
+ * its own schema when its reader is built, and no scan may change
spark.sql.parquet.enableVectorizedReader,
+ * which Spark reads to decide whether the scan output still needs an
UnsafeRow conversion.
+ */
+class TestVectorizedReadDecisionPerScan extends HoodieSparkSqlTestBase with
AdaptiveSparkPlanHelper {
+
+ private val vectorizedReaderConf = "spark.sql.parquet.enableVectorizedReader"
+ // Leaf fields of the payload struct; with them the table exceeds
spark.sql.codegen.maxFields, so a
+ // select * scan is planned row-based without a supportBatch call
+ private val payloadFieldCount = 120
+
+ Seq("cow", "mor").foreach { tableType =>
+ test(s"Test wide scan after narrow scan decides vectorized read per scan
for $tableType table") {
+ withSQLConf(vectorizedReaderConf -> "true",
"spark.sql.codegen.maxFields" -> "100") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ updateOneFileGroup(tableName, tableType, createWideTable(tableName,
tableType, tmp))
+
+ // The narrow scan is asked supportBatch and may read vectorized
+ checkNarrowScan(tableName, updatedRows)
+ checkAnswer(s"select count(*) from $tableName")(Seq(3))
+ assertResult("true")(spark.conf.get(vectorizedReaderConf))
+
+ // The wide scan is never asked, and must not inherit the narrow
scan's answer
+ assert(!scanIsBatched(s"select * from $tableName"))
+ checkWideScan(tableName, updatedRows)
+ assertResult("true")(spark.conf.get(vectorizedReaderConf))
+
+ // With the vectorized reader disabled Spark skips the UnsafeRow
conversion of the scan output, so a
+ // wide scan handed a leftover vectorized reader fails with
+ // ClassCastException: ColumnarBatchRow cannot be cast to UnsafeRow
+ withSQLConf(vectorizedReaderConf -> "false") {
+ checkWideScan(tableName, updatedRows)
+ checkNarrowScan(tableName, updatedRows)
+ assertResult("false")(spark.conf.get(vectorizedReaderConf))
+ }
+ }
+ }
+ }
+
+ test(s"Test wide scan keeps vectorized reads enabled for later scans for
$tableType table") {
+ withSQLConf(vectorizedReaderConf -> "true",
"spark.sql.codegen.maxFields" -> "100") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ updateOneFileGroup(tableName, tableType, createWideTable(tableName,
tableType, tmp))
+
+ checkWideScan(tableName, updatedRows)
+ assertResult("true")(spark.conf.get(vectorizedReaderConf))
+
+ // Only COW scans return batches; MOR merges rows, so its scans are
never columnar
+ if (tableType == "cow") {
+ assert(scanIsBatched(s"select id, name, dt from $tableName"))
+ assert(scanIsBatched(s"select count(*) from $tableName"))
+ }
+ checkNarrowScan(tableName, updatedRows)
+ checkAnswer(s"select count(*) from $tableName")(Seq(3))
+ assertResult("true")(spark.conf.get(vectorizedReaderConf))
+ }
+ }
+ }
+
+ // Every scan now asks canReadVectorized, so a format it does not know
fails every read, not only narrow ones
+ test(s"Test narrow and wide scans of a Vortex table read row-based for
$tableType table") {
+
assume(SparkAdapterSupport.sparkAdapter.createVortexFileReader(vectorized =
false, spark.sessionState.conf,
+ Map.empty, new Configuration()).isDefined, "Vortex reads need a Spark
4.x adapter")
+ // Vortex files are written from Spark records only
+ withSQLConf(vectorizedReaderConf -> "true",
"spark.sql.codegen.maxFields" -> "100",
+ HoodieWriteConfig.RECORD_MERGE_IMPL_CLASSES.key ->
classOf[DefaultSparkRecordMerger].getName) {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ createWideTable(tableName, tableType, tmp,
Map(HoodieTableConfig.BASE_FILE_FORMAT.key -> "VORTEX"))
+
+ assert(!scanIsBatched(s"select id, name, dt from $tableName"))
+ checkNarrowScan(tableName, insertedRows)
+ assert(!scanIsBatched(s"select * from $tableName"))
+ checkWideScan(tableName, insertedRows)
+ assertResult("true")(spark.conf.get(vectorizedReaderConf))
+ }
+ }
+ }
+ }
+
+ // Before Spark 3.5, Hudi never reads a table with a timestamp-millis field
vectorized (#14161), so these scans are
+ // row-based while spark.sql.parquet.enableVectorizedReader stays true.
Spark then converts the scan output to
+ // UnsafeRow, which the row reader already produces, so the conversion only
copies each row.
+ Seq(HoodieTableType.COPY_ON_WRITE -> "cow", HoodieTableType.MERGE_ON_READ ->
"mor").foreach { case (tableType, label) =>
+ test(s"Test narrow and wide scans of a timestamp-millis table with the
vectorized reader enabled for $label table") {
+ withSQLConf(vectorizedReaderConf -> "true",
"spark.sql.codegen.maxFields" -> "100") {
+ withTempDir { tmp =>
+ val tableName = generateTableName
+ val basePath = s"${tmp.getCanonicalPath}/$tableName"
+ writeTimestampMillisTable(tableName, basePath, tableType)
+ // Read by path: without a Hive catalog, a catalog table hands Hudi
its Spark schema, which has no
+ // timestamp-millis type. One view keeps one file format instance
for every scan below.
+
spark.read.format("hudi").load(basePath).createOrReplaceTempView(tableName)
+ val expected = timestampMillisRows.map { case (id, name, eventTime,
payloadBase) =>
+ (id, name, eventTime, payloadBase + 1, payloadBase +
payloadFieldCount)
+ }
+
+ val narrowScan = s"select id, name, event_time from $tableName"
+ // Only COW scans return batches, and only from Spark 3.5 on for
this table
+ assertResult(HoodieSparkUtils.gteqSpark3_5 && tableType ==
HoodieTableType.COPY_ON_WRITE)(scanIsBatched(narrowScan))
+ val narrow = spark.sql(narrowScan).collect().map { row =>
+ (row.getAs[Int]("id"), row.getAs[String]("name"),
row.getAs[Timestamp]("event_time").getTime)
+ }.sorted.toSeq
+ assertResult(expected.map(r => (r._1, r._2, r._3)))(narrow)
+ assertResult("true")(spark.conf.get(vectorizedReaderConf))
+
+ val wideScan = s"select * from $tableName"
+
assert(WholeStageCodegenExec.isTooManyFields(spark.sessionState.conf,
spark.table(tableName).schema),
+ "select * must be too wide for whole-stage codegen, otherwise
Spark asks supportBatch for it as well")
+ assert(!scanIsBatched(wideScan))
+ val wide = spark.sql(wideScan).collect().map { row =>
+ val payload = row.getAs[Row]("payload")
+ (row.getAs[Int]("id"), row.getAs[String]("name"),
row.getAs[Timestamp]("event_time").getTime,
+ payload.getAs[Int]("f1"),
payload.getAs[Int](s"f$payloadFieldCount"))
+ }.sorted.toSeq
+ assertResult(expected)(wide)
+ assertResult("true")(spark.conf.get(vectorizedReaderConf))
+ spark.catalog.dropTempView(tableName)
+ }
+ }
+ }
+ }
+
+ // Rows are (id, name, event_time in epoch millis, payload base); the second
commit updates id 2
+ private val insertedTimestampMillisRows = Seq(
+ (1, "a1", 1672567200124L, 1000), (2, "a2", 1672567200125L, 2000), (3,
"a3", 1672567200126L, 3000))
+ private val updatedTimestampMillisRow = (2, "a2_updated", 1672653600456L,
20000)
+ private val timestampMillisRows = insertedTimestampMillisRows.map { row =>
+ if (row._1 == updatedTimestampMillisRow._1) updatedTimestampMillisRow else
row
+ }
+
+ // Spark has no timestamp-millis type, so SQL and DataFrame writes cannot
produce this schema
+ private lazy val timestampMillisSchema: Schema = {
+ val payload = (1 to payloadFieldCount)
+ .foldLeft(SchemaBuilder.record("payload").fields())((fields, i) =>
fields.requiredInt(s"f$i"))
+ .endRecord()
+ SchemaBuilder.record("ts_millis_record").fields()
+ .requiredInt("id")
+ .requiredString("name")
+
.name("event_time").`type`(LogicalTypes.timestampMillis().addToSchema(Schema.create(Schema.Type.LONG))).noDefault()
+ .name("payload").`type`(payload).noDefault()
+ .endRecord()
+ }
+
+ private def timestampMillisRecord(row: (Int, String, Long, Int)):
HoodieRecord[IndexedRecord] = {
+ val (id, name, eventTime, payloadBase) = row
+ val payload = new
GenericData.Record(timestampMillisSchema.getField("payload").schema())
+ (1 to payloadFieldCount).foreach(i => payload.put(s"f$i", payloadBase + i))
+ val record = new GenericData.Record(timestampMillisSchema)
+ record.put("id", id)
+ record.put("name", name)
+ record.put("event_time", eventTime)
+ record.put("payload", payload)
+ new HoodieAvroIndexedRecord(new HoodieKey(id.toString, ""), record)
+ }
+
+ /** Writes three rows, then updates one, which lands in a log file on a MOR
table. */
+ private def writeTimestampMillisTable(tableName: String, basePath: String,
tableType: HoodieTableType): Unit = {
+ HoodieTableMetaClient.newTableBuilder()
+ .setTableName(tableName)
+ .setTableType(tableType)
+ .setRecordKeyFields("id")
+ .setPartitionFields("")
+ .setRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING)
+
.initTable(HadoopFSUtils.getStorageConf(spark.sparkContext.hadoopConfiguration),
basePath)
+ val writeConfig = HoodieWriteConfig.newBuilder()
+ .withPath(basePath)
+ .forTable(tableName)
+ .withSchema(timestampMillisSchema.toString)
+ .withParallelism(1, 1)
+ .withRecordMergeMode(RecordMergeMode.COMMIT_TIME_ORDERING)
+
.withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.SIMPLE).build())
+
.withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build())
+ .withEmbeddedTimelineServerEnabled(false)
+ .build()
+ val jsc = JavaSparkContext.fromSparkContext(spark.sparkContext)
+ val client = new SparkRDDWriteClient[IndexedRecord](new
HoodieSparkEngineContext(jsc), writeConfig)
+ try {
+ Seq(insertedTimestampMillisRows -> true, Seq(updatedTimestampMillisRow)
-> false).foreach { case (rows, isInsert) =>
+ val records = jsc.parallelize(rows.map(timestampMillisRecord).asJava,
1)
+ val instant = client.startCommit()
+ val statuses = if (isInsert) client.insert(records, instant) else
client.upsert(records, instant)
+ assert(client.commit(instant, jsc.parallelize(statuses.collect(), 1)))
+ }
+ } finally {
+ client.close()
+ }
+ }
+
+ // Rows of the wide table as (id, name, dt). Partition p2 holds only id 3,
so each partition is its own file group.
+ private val insertedRows = Seq((1, "a1", "p1"), (2, "a2", "p1"), (3, "a3",
"p2"))
+ private val updatedRows = insertedRows.map {
+ case (3, _, dt) => (3, "a3_updated", dt)
+ case row => row
+ }
+
+ /** Creates and fills the wide table, and returns its path. */
+ private def createWideTable(tableName: String, tableType: String, tmp: File,
+ extraProps: Map[String, String] = Map.empty):
String = {
+ val tablePath = s"${tmp.getCanonicalPath}/$tableName"
+ val payloadType = (1 to payloadFieldCount).map(i => s"f$i
int").mkString("struct<", ", ", ">")
+ val extraTblProps = extraProps.map { case (k, v) => s",\n '$k' = '$v'"
}.mkString
+ spark.sql(
+ s"""
+ |create table $tableName (
+ | id int,
+ | name string,
+ | payload $payloadType,
+ | ts long,
+ | dt string
+ |) using hudi
+ | partitioned by (dt)
+ | location '$tablePath'
+ | tblproperties (
+ | type = '$tableType',
+ | primaryKey = 'id',
+ | orderingFields = 'ts'$extraTblProps
+ | )
+ """.stripMargin)
+ val rows = insertedRows.map { case (id, name, dt) =>
+ val payload = (1 to payloadFieldCount).map(i => s"'f$i', ${id * 1000 +
i}").mkString("named_struct(", ", ", ")")
+ s"select $id as id, '$name' as name, $payload as payload, ${id * 1000}L
as ts, '$dt' as dt"
+ }
+ spark.sql(s"insert into $tableName ${rows.mkString(" union all ")}")
+ assert(WholeStageCodegenExec.isTooManyFields(spark.sessionState.conf,
spark.table(tableName).schema),
+ "select * must be too wide for whole-stage codegen, otherwise Spark asks
supportBatch for it as well")
+ tablePath
+ }
+
+ /**
+ * Renames id 3, so on a MOR table p2 gets a base+log file slice, read by
the file group reader over the row
+ * base reader, while p1 keeps a base-only slice, read by the base reader,
which is vectorized when the scan
+ * allows it. Every MOR scan after this mixes the two readers.
+ */
+ private def updateOneFileGroup(tableName: String, tableType: String,
tablePath: String): Unit = {
+ spark.sql(s"update $tableName set name = 'a3_updated' where id = 3")
+ assertResult(if (tableType == "mor") Set("p2") else
Set.empty[String])(partitionsWithLogFiles(tablePath))
+ }
+
+ private def partitionsWithLogFiles(tablePath: String): Set[String] = {
+ val files = Files.walk(Paths.get(tablePath))
+ try {
+ files.iterator().asScala
+ .filter(f => Files.isRegularFile(f) &&
f.getFileName.toString.contains(".log.") && !f.toString.contains("/.hoodie/"))
+ .map(_.getParent.getFileName.toString.stripPrefix("dt="))
+ .toSet
+ } finally {
+ files.close()
+ }
+ }
+
+ /** Runs a scan narrower than spark.sql.codegen.maxFields, and checks the
rows that came back. */
+ private def checkNarrowScan(tableName: String, expected: Seq[(Int, String,
String)]): Unit = {
+ val actual = spark.sql(s"select id, name, dt from
$tableName").collect().map { row =>
+ (row.getAs[Int]("id"), row.getAs[String]("name"),
row.getAs[String]("dt"))
+ }.sorted.toSeq
+ assertResult(expected)(actual)
+ }
+
+ /** Runs select * so that the scan stays wide, and checks the rows that came
back. */
+ private def checkWideScan(tableName: String, expected: Seq[(Int, String,
String)]): Unit = {
+ val actual = spark.sql(s"select * from $tableName").collect().map { row =>
+ val payload = row.getAs[Row]("payload")
+ (row.getAs[Int]("id"), row.getAs[String]("name"),
row.getAs[String]("dt"),
+ payload.getAs[Int]("f1"), payload.getAs[Int](s"f$payloadFieldCount"))
+ }.sorted.toSeq
+ assertResult(expected.map { case (id, name, dt) => (id, name, dt, id *
1000 + 1, id * 1000 + payloadFieldCount) })(actual)
+ }
+
+ private def scanIsBatched(sql: String): Boolean = {
+ val plan = spark.sql(sql).queryExecution.executedPlan
+ collectFirst(plan) { case scan: FileSourceScanExec =>
scan.supportsColumnar }
+ .getOrElse(fail(s"No FileSourceScanExec in the plan of [$sql]:\n$plan"))
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
index c0b6bb5c7934..aa54553799ec 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestVariantShreddingMixedLayouts.scala
@@ -1073,9 +1073,11 @@ class TestVariantShreddingMixedLayouts extends
HoodieSparkSqlTestBase with Varia
withSQLConf("spark.sql.variant.pushVariantIntoScan" -> "false") {
checkAnswer(s"select id, ts from $tableName order by id")(Seq(1,
1000), Seq(2, 1000))
}
- // count(*) is deliberately not pinned: it fails on any
schema-on-read variant table once
- // an internal schema is committed, rename or not (#20139), and the
existing pin in
- // "Schema-on-read reads of shredded variant files fail fast" is
green only through #20140.
+ // count(*) reads no column data, so it skips the schema-on-read
merge
+ // (ParquetSchemaEvolutionUtils.shouldUseInternalSchema) and counts
on every leg. The merge
+ // used to request the variant as a plain struct, which Spark 4.x's
vectorized reader rejects
+ // for a VARIANT-annotated group, so COW counts failed whether the
file was shredded or not.
+ checkAnswer(s"select count(*) from $tableName")(Seq(2))
}
}
}
@@ -2356,6 +2358,32 @@ class TestVariantShreddingMixedLayouts extends
HoodieSparkSqlTestBase with Varia
}
}
+ test("Variant read does not mutate the session vectorized reader
configuration") {
+ assume(HoodieSparkUtils.gteqSpark4_1, SPARK_4_1_GATE)
+
+ // A variant read falls back to row-based reads. That decision belongs to
the scan alone: it used
+ // to be written into spark.sql.parquet.enableVectorizedReader, so every
later read in the session
+ // lost vectorization.
+ withVariantTable(
+ "vectorized reader session conf",
+ "cow",
+ recordTypes = Seq(HoodieRecordType.SPARK)) { (tableName, _, _) =>
+
+ spark.sql(
+ s"""insert into $tableName values (1, parse_json('{"key":"v1"}'),
1000)""")
+
+ withSQLConf("spark.sql.parquet.enableVectorizedReader" -> "true") {
+ checkAnswer(
+ s"select id, cast(v as string) from $tableName")(
+ Seq(1, """{"key":"v1"}"""))
+
+ assert(
+ spark.conf.get("spark.sql.parquet.enableVectorizedReader") == "true",
+ "A query-local vectorization fallback must not mutate the session
configuration")
+ }
+ }
+ }
+
//
-----------------------------------------------------------------------------------------------
// G. Schema evolution
//
-----------------------------------------------------------------------------------------------