This is an automated email from the ASF dual-hosted git repository.
yihua 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 2fc6486f9b29 perf(spark): ship file group reader scan state once per
executor (#20077)
2fc6486f9b29 is described below
commit 2fc6486f9b298f40a232c062da3bb461de52caa3
Author: Y Ethan Guo <[email protected]>
AuthorDate: Mon Oct 5 14:47:14 2026 -0700
perf(spark): ship file group reader scan state once per executor (#20077)
---
.../hudi/common/table/HoodieTableMetaClient.java | 4 +-
.../common/table/TestHoodieTableMetaClient.java | 41 +++
.../hudi/HoodieHadoopFsRelationFactory.scala | 2 +-
.../HoodieFileGroupReaderBasedFileFormat.scala | 310 ++---------------
.../parquet/HoodieFileGroupReaderFunction.scala | 376 +++++++++++++++++++++
.../TestReadOptionsDoNotOverrideTableConfig.java | 247 ++++++++++++++
.../TestHoodieFileGroupReaderFunction.scala | 112 ++++++
7 files changed, 812 insertions(+), 280 deletions(-)
diff --git
a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java
b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java
index d7b120828a94..87218cae4f40 100644
---
a/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java
+++
b/hudi-common/src/main/java/org/apache/hudi/common/table/HoodieTableMetaClient.java
@@ -167,7 +167,8 @@ public class HoodieTableMetaClient implements Serializable {
// In-memory cache for archived timeline based on the start instant time
// Only one entry should be present in this map
- private final Map<String, HoodieArchivedTimeline> archivedTimelineMap = new
HashMap<>();
+ // Not serialized, so a copy shipped to an executor does not carry the
loaded archived instants and their details
+ private transient Map<String, HoodieArchivedTimeline> archivedTimelineMap =
new HashMap<>();
protected StoragePath basePath;
protected StoragePath metaPath;
@@ -388,6 +389,7 @@ public class HoodieTableMetaClient implements Serializable {
in.defaultReadObject();
storage = null; // will be lazily initialized
+ archivedTimelineMap = new HashMap<>();
}
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
diff --git
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieTableMetaClient.java
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieTableMetaClient.java
index 2754dc35f8bd..6ee96663cd63 100644
---
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieTableMetaClient.java
+++
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/table/TestHoodieTableMetaClient.java
@@ -24,6 +24,7 @@ import org.apache.hudi.common.model.HoodieIndexMetadata;
import org.apache.hudi.common.model.HoodieTableType;
import org.apache.hudi.common.model.MetaFieldsMode;
import org.apache.hudi.common.table.timeline.HoodieActiveTimeline;
+import org.apache.hudi.common.table.timeline.HoodieArchivedTimeline;
import org.apache.hudi.common.table.timeline.HoodieInstant;
import org.apache.hudi.common.table.timeline.HoodieTimeline;
import org.apache.hudi.common.testutils.HoodieCommonTestHarness;
@@ -43,20 +44,27 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.Map;
import java.util.Properties;
+import java.util.Set;
import static
org.apache.hudi.common.testutils.HoodieTestUtils.INSTANT_GENERATOR;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -106,6 +114,39 @@ class TestHoodieTableMetaClient extends
HoodieCommonTestHarness {
assertEquals("val",
metaClient.getActiveTimeline().readCommitMetadata(completedInstant).getExtraMetadata().get("key"));
}
+ /**
+ * The archived timeline is cached by the meta client that loaded it, and a
serialized copy (as shipped to
+ * executors) must not carry it, since it holds every loaded archived
instant and its details.
+ */
+ @Test
+ void testArchivedTimelineCacheIsNotSerialized() throws IOException,
ClassNotFoundException {
+ HoodieArchivedTimeline archivedTimeline = metaClient.getArchivedTimeline();
+ assertSame(archivedTimeline, metaClient.getArchivedTimeline());
+
+ Set<Class<?>> serializedClasses = new HashSet<>();
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ try (ObjectOutputStream out = new ObjectOutputStream(bytes) {
+ {
+ enableReplaceObject(true);
+ }
+
+ @Override
+ protected Object replaceObject(Object obj) {
+ serializedClasses.add(obj.getClass());
+ return obj;
+ }
+ }) {
+ out.writeObject(metaClient);
+ }
+
assertTrue(serializedClasses.stream().noneMatch(HoodieArchivedTimeline.class::isAssignableFrom),
+ "The archived timeline was serialized with the meta client: " +
serializedClasses);
+
+ try (ObjectInputStream in = new ObjectInputStream(new
ByteArrayInputStream(bytes.toByteArray()))) {
+ HoodieTableMetaClient deserializedMetaClient = (HoodieTableMetaClient)
in.readObject();
+ assertNotNull(deserializedMetaClient.getArchivedTimeline());
+ }
+ }
+
@Test
void testCommitTimeline() throws IOException {
HoodieActiveTimeline activeTimeline = metaClient.getActiveTimeline();
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieHadoopFsRelationFactory.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieHadoopFsRelationFactory.scala
index a77329b857bb..a4e8f18bf499 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieHadoopFsRelationFactory.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieHadoopFsRelationFactory.scala
@@ -247,7 +247,7 @@ abstract class HoodieBaseHadoopFsRelationFactory(val
sqlContext: SQLContext,
HoodieTableSchema(tableStructSchema, tableSchema, internalSchemaOpt),
tableConfig.getTableName, queryTimestamp.get, getMandatoryFields, isMOR,
isBootstrap,
isIncremental, validCommits, shouldUseRecordPosition, getRequiredFilters,
- tableConfig.isMultipleBaseFileFormatsEnabled,
tableConfig.getBaseFileFormat, getInstantRange)
+ tableConfig.isMultipleBaseFileFormatsEnabled,
tableConfig.getBaseFileFormat, getInstantRange, Some(metaClient))
}
override def buildBucketSpec(): Option[BucketSpec] = None
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 241bdff63178..7a0be7d5b538 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
@@ -17,41 +17,32 @@
package org.apache.spark.sql.execution.datasources.parquet
-import org.apache.hudi.{HoodieFileIndex, HoodiePartitionCDCFileGroupMapping,
HoodiePartitionFileSliceMapping, HoodieSchemaConversionUtils, HoodieSparkUtils,
HoodieTableSchema, SparkAdapterSupport, SparkFileFormatInternalRowReaderContext}
-import org.apache.hudi.cdc.{CDCFileGroupIterator, HoodieCDCFileGroupSplit,
HoodieCDCFileIndex}
+import org.apache.hudi.{HoodieFileIndex, HoodieSchemaConversionUtils,
HoodieSparkUtils, HoodieTableSchema, SparkAdapterSupport,
SparkFileFormatInternalRowReaderContext}
import org.apache.hudi.client.common.HoodieSparkEngineContext
import org.apache.hudi.client.utils.SparkInternalSchemaConverter
-import org.apache.hudi.common.config.{HoodieMemoryConfig, HoodieReaderConfig,
TypedProperties}
-import org.apache.hudi.common.fs.FSUtils
+import org.apache.hudi.common.config.{HoodieMemoryConfig, TypedProperties}
import org.apache.hudi.common.model.HoodieFileFormat
import org.apache.hudi.common.schema.HoodieSchema
import org.apache.hudi.common.schema.HoodieSchemaRepair
import org.apache.hudi.common.schema.HoodieSchemaUtils
import org.apache.hudi.common.schema.internal.InternalSchema
-import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient,
ParquetTableSchemaResolver}
+import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
import org.apache.hudi.common.table.log.InstantRange
-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.{ConfigUtils, Option => HOption}
-import org.apache.hudi.common.util.collection.ClosableIterator
-import org.apache.hudi.data.CloseableIteratorListener
+import org.apache.hudi.common.table.read.HoodieFileGroupReader
+import org.apache.hudi.common.util.{Option => HOption}
import org.apache.hudi.exception.HoodieNotSupportedException
import org.apache.hudi.io.MergeUtils
import
org.apache.hudi.io.storage.HoodieSparkParquetReader.ENABLE_LOGICAL_TIMESTAMP_REPAIR
-import org.apache.hudi.io.storage.VectorConversionUtils
import org.apache.hudi.storage.StorageConfiguration
import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration
import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.{FileStatus, Path}
import org.apache.hadoop.mapreduce.Job
-import org.apache.parquet.schema.MessageType
import org.apache.spark.api.java.JavaSparkContext
import org.apache.spark.internal.Logging
-import
org.apache.spark.sql.HoodieCatalystExpressionUtils.generateUnsafeProjection
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.InternalRow
-import org.apache.spark.sql.catalyst.expressions.{JoinedRow, UnsafeProjection}
import org.apache.spark.sql.execution.datasources.{OutputWriterFactory,
PartitionedFile, SparkColumnarFileReader, SparkSchemaTransformUtils}
import org.apache.spark.sql.execution.datasources.orc.OrcUtils
import org.apache.spark.sql.execution.vectorized.{OffHeapColumnVector,
OnHeapColumnVector}
@@ -59,11 +50,8 @@ import
org.apache.spark.sql.hudi.MultipleColumnarFileFormatReader
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.sources.Filter
import org.apache.spark.sql.types.{ArrayType, DataType, MapType, StructType}
-import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnarBatchUtils}
import org.apache.spark.util.SerializableConfiguration
-import java.io.Closeable
-
import scala.collection.JavaConverters.mapAsJavaMapConverter
trait HoodieFormatTrait {
@@ -94,17 +82,12 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
requiredFilters: Seq[Filter],
isMultipleBaseFileFormatsEnabled:
Boolean,
hoodieFileFormat: HoodieFileFormat,
- instantRangeOpt:
HOption[InstantRange] = HOption.empty())
+ instantRangeOpt:
HOption[InstantRange] = HOption.empty(),
+ @transient tableMetaClient:
Option[HoodieTableMetaClient] = None)
extends ParquetFileFormat with SparkAdapterSupport with HoodieFormatTrait
with Logging with Serializable {
private lazy val schema = tableSchema.schema
- private lazy val tableSchemaAsMessageType: HOption[MessageType] = {
- HOption.ofNullable(
- ParquetTableSchemaResolver.convertAvroSchemaToParquet(schema, new
Configuration())
- )
- }
-
private lazy val hasTimestampMillisFieldInTableSchema =
HoodieSchemaRepair.hasTimestampMillisField(schema)
private lazy val supportBatchWithTableSchema = HoodieSparkUtils.gteqSpark3_5
|| !hasTimestampMillisFieldInTableSchema
override def shortName(): String = "HudiFileGroup"
@@ -127,7 +110,7 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
/**
* Cached result of vector column detection keyed by schema identity.
- * Avoids re-parsing metadata on repeated supportBatch / readBaseFile calls
with the same schema.
+ * Avoids re-parsing metadata on repeated supportBatch calls with the same
schema.
*/
@transient private var cachedVectorDetection: (StructType, Map[Int,
HoodieSchema.Vector]) = _
@@ -358,97 +341,35 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
}
val broadcastedStorageConf = spark.sparkContext.broadcast(new
SerializableConfiguration(augmentedStorageConf.unwrap()))
- val fileIndexProps: TypedProperties =
HoodieFileIndex.getConfigProperties(spark, options, null)
+ val cdcProps: TypedProperties = HoodieFileIndex.getConfigProperties(spark,
options, null)
+ cdcProps.setProperty(HoodieTableConfig.HOODIE_TABLE_NAME_KEY, tableName)
val engineContext = new HoodieSparkEngineContext(new
JavaSparkContext(spark.sparkContext))
val maxMemoryPerCompaction =
MergeUtils.getMaxMemoryPerCompaction(engineContext.getTaskContextSupplier,
options.asJava)
- // Create metaclient on driver to avoid expensive operations on executors
- val metaClient: HoodieTableMetaClient = HoodieTableMetaClient
- .builder().setConf(augmentedStorageConf).setBasePath(tablePath).build
-
- (file: PartitionedFile) => {
- // executor
- val storageConf = new
HadoopStorageConfiguration(broadcastedStorageConf.value.value)
- val iter = file.partitionValues match {
- // Snapshot or incremental queries.
- case fileSliceMapping: HoodiePartitionFileSliceMapping =>
- val fileGroupName = FSUtils.getFileIdFromFilePath(sparkAdapter
- .getSparkPartitionedFileUtils.getPathFromPartitionedFile(file))
- fileSliceMapping.getSlice(fileGroupName) match {
- case Some(fileSlice) if !isCount && (requiredSchema.nonEmpty ||
fileSlice.getLogFiles.findAny().isPresent) =>
- // requiredFilters preserve Spark's row-level filtering
semantics, while instantRangeOpt
- // keeps out-of-range records from participating in the
file-group merge itself.
- val readerContext = new SparkFileFormatInternalRowReaderContext(
- fileGroupBaseFileReader.value, filters, requiredFilters,
storageConf, metaClient.getTableConfig,
- sparkRequiredSchema = Some(requiredSchema), instantRangeOpt =
instantRangeOpt)
-
readerContext.enableLogicalTimestampFieldRepair(storageConf.getBoolean(ENABLE_LOGICAL_TIMESTAMP_REPAIR,
true))
- val props = metaClient.getTableConfig.getProps
- options.foreach(kv => props.setProperty(kv._1, kv._2))
- props.put(HoodieMemoryConfig.MAX_MEMORY_FOR_MERGE.key(),
String.valueOf(maxMemoryPerCompaction))
- val baseFileLength = if (fileSlice.getBaseFile.isPresent) {
- fileSlice.getBaseFile.get.getFileSize
- } else {
- 0
- }
- val reader: HoodieRecordReader[InternalRow] =
- if (LsmReaderUtils.shouldUseLsmReader(
- metaClient.getTableConfig,
- ConfigUtils.getStringWithAltKeys(props,
HoodieReaderConfig.MERGE_TYPE, true))) {
- HoodieLsmFileGroupReader.builder[InternalRow]()
- .withReaderContext(readerContext)
- .withHoodieTableMetaClient(metaClient)
- .withLatestCommitTime(queryTimestamp)
- .withBaseFileOption(fileSlice.getBaseFile)
- .withLogFiles(fileSlice.getLogFiles)
- .withPartitionPath(fileSlice.getPartitionPath)
- .withDataSchema(dataSchema)
- .withRequestedSchema(requestedSchema)
- .withInternalSchemaOpt(internalSchemaOpt)
- .withProps(props)
- .withStart(file.start)
- .withLength(baseFileLength)
- .build()
- } else {
- HoodieFileGroupReader.builder[InternalRow]()
- .withReaderContext(readerContext)
- .withHoodieTableMetaClient(metaClient)
- .withLatestCommitTime(queryTimestamp)
- .withBaseFileOption(fileSlice.getBaseFile)
- .withLogFiles(fileSlice.getLogFiles)
- .withPartitionPath(fileSlice.getPartitionPath)
- .withDataSchema(dataSchema)
- .withRequestedSchema(requestedSchema)
- .withInternalSchemaOpt(internalSchemaOpt)
- .withProps(props)
- .withStart(file.start)
- .withLength(baseFileLength)
- .withShouldUseRecordPosition(shouldUseRecordPosition)
- .build()
- }
- // Append partition values to rows and project to output schema
- appendPartitionAndProject(
- reader.getClosableIterator,
- projectionInputSchema,
- remainingPartitionSchema,
- outputSchema,
- fileSliceMapping.getPartitionValues,
- fixedPartitionIndexes)
-
- case _ =>
- readBaseFile(file, baseFileReader.value, requestedStructType,
remainingPartitionSchema, fixedPartitionIndexes,
- readRequiredSchema, partitionSchema, outputSchema, filters ++
requiredFilters, storageConf)
- }
- // CDC queries.
- case hoodiePartitionCDCFileGroupSliceMapping:
HoodiePartitionCDCFileGroupMapping =>
- buildCDCRecordIterator(hoodiePartitionCDCFileGroupSliceMapping,
fileGroupBaseFileReader.value, storageConf, fileIndexProps, requiredSchema,
metaClient)
-
- case _ =>
- readBaseFile(file, baseFileReader.value, requestedStructType,
remainingPartitionSchema, fixedPartitionIndexes,
- readRequiredSchema, partitionSchema, outputSchema, filters ++
requiredFilters, storageConf)
- }
- CloseableIteratorListener.addListener(iter)
+ // The relation's meta client carries the timeline the scan was planned
against; build one only when the
+ // format is used without a relation. The field is null rather than None
on a deserialized format.
+ val metaClient: HoodieTableMetaClient =
Option(tableMetaClient).flatten.getOrElse(HoodieTableMetaClient
+ .builder().setConf(augmentedStorageConf).setBasePath(tablePath).build)
+ val readerProps = TypedProperties.copy(metaClient.getTableConfig.getProps)
+ options.foreach(kv => readerProps.setProperty(kv._1, kv._2))
+ readerProps.put(HoodieMemoryConfig.MAX_MEMORY_FOR_MERGE.key(),
String.valueOf(maxMemoryPerCompaction))
+
+ val (baseReadRequiredSchema, readVectorColumns) =
withVectorRewrite(readRequiredSchema)
+ val baseFileReadSchemas = if (readVectorColumns.nonEmpty) {
+ val (baseOutputSchema, outputVectorColumns) =
withVectorRewrite(outputSchema)
+ BaseFileReadSchemas(baseReadRequiredSchema, readVectorColumns,
baseOutputSchema, outputVectorColumns,
+ withVectorRewrite(requestedStructType)._1)
+ } else {
+ BaseFileReadSchemas(baseReadRequiredSchema, readVectorColumns,
outputSchema, Map.empty, requestedStructType)
}
+
+ val state = new HoodieFileGroupReadState(metaClient, tableSchema,
queryTimestamp, readerProps, cdcProps,
+ dataSchema, requestedSchema, internalSchemaOpt, instantRangeOpt,
shouldUseRecordPosition, isCount, filters,
+ requiredFilters, requiredSchema, partitionSchema,
remainingPartitionSchema, fixedPartitionIndexes, outputSchema,
+ projectionInputSchema, baseFileReadSchemas)
+ new HoodieFileGroupReaderFunction(baseFileReader, fileGroupBaseFileReader,
broadcastedStorageConf,
+ spark.sparkContext.broadcast(JavaSerializedValue(state)))
}
private def buildBaseFileReader(spark: SparkSession,
@@ -482,85 +403,6 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
}
}
- private def buildCDCRecordIterator(hoodiePartitionCDCFileGroupSliceMapping:
HoodiePartitionCDCFileGroupMapping,
- baseFileReader: SparkColumnarFileReader,
- storageConf:
StorageConfiguration[Configuration],
- props: TypedProperties,
- requiredSchema: StructType,
- metaClient: HoodieTableMetaClient):
Iterator[InternalRow] = {
- val fileSplits =
hoodiePartitionCDCFileGroupSliceMapping.getFileSplits().toArray
- val cdcFileGroupSplit: HoodieCDCFileGroupSplit =
HoodieCDCFileGroupSplit(fileSplits)
- props.setProperty(HoodieTableConfig.HOODIE_TABLE_NAME_KEY, tableName)
- val cdcSchema = HoodieCDCFileIndex.FULL_CDC_SPARK_SCHEMA
- new CDCFileGroupIterator(
- cdcFileGroupSplit,
- metaClient,
- storageConf,
- baseFileReader,
- tableSchema,
- cdcSchema,
- requiredSchema,
- props)
- }
-
- private def appendPartitionAndProject(iter: ClosableIterator[InternalRow],
- inputSchema: StructType,
- partitionSchema: StructType,
- to: StructType,
- partitionValues: InternalRow,
- fixedPartitionIndexes: Set[Int]):
Iterator[InternalRow] = {
- if (partitionSchema.isEmpty) {
- //'inputSchema' and 'to' should be the same so the projection will just
be an identity func
- projectSchema(iter, inputSchema, to)
- } else {
- val fixedPartitionValues = if (partitionSchema.length ==
partitionValues.numFields) {
- //need to append all of the partition fields
- partitionValues
- } else {
- //some partition fields read from file, some were not
- getFixedPartitionValues(partitionValues, partitionSchema,
fixedPartitionIndexes)
- }
- val unsafeProjection =
generateOutputProjection(StructType(inputSchema.fields ++
partitionSchema.fields), to)
- val joinedRow = new JoinedRow()
- makeCloseableFileGroupMappingRecordIterator(iter, d =>
unsafeProjection(joinedRow(d, fixedPartitionValues)))
- }
- }
-
- private def projectSchema(iter: ClosableIterator[InternalRow],
- from: StructType,
- to: StructType): Iterator[InternalRow] = {
- val unsafeProjection = generateOutputProjection(from, to)
- makeCloseableFileGroupMappingRecordIterator(iter, d => unsafeProjection(d))
- }
-
- /**
- * The scan's output projection. Stays the by-name top-level projection
unless a column comes out of the
- * reader with nested fields Spark did not ask for (see
`projectionInputSchema` in
- * buildReaderWithPartitionValues), in which case those are dropped by name
at every depth.
- */
- private def generateOutputProjection(from: StructType, to: StructType):
UnsafeProjection = {
- val hasWiderNestedInput = to.fields.exists { f =>
- from.getFieldIndex(f.name).exists(i =>
SparkSchemaTransformUtils.needsNestedPruning(from.fields(i).dataType,
f.dataType))
- }
- if (hasWiderNestedInput) {
- SparkSchemaTransformUtils.generateNestedPruningProjection(from, to)
- } else {
- generateUnsafeProjection(from, to)
- }
- }
-
- private def
makeCloseableFileGroupMappingRecordIterator(closeableFileGroupRecordIterator:
ClosableIterator[InternalRow],
- mappingFunction:
Function[InternalRow, InternalRow]): Iterator[InternalRow] = {
- CloseableIteratorListener.addListener(closeableFileGroupRecordIterator)
- new Iterator[InternalRow] with Closeable {
- override def hasNext: Boolean = closeableFileGroupRecordIterator.hasNext
-
- override def next(): InternalRow =
mappingFunction(closeableFileGroupRecordIterator.next())
-
- override def close(): Unit = closeableFileGroupRecordIterator.close()
- }
- }
-
private def detectVectorColumns(schema: StructType): Map[Int,
HoodieSchema.Vector] =
SparkFileFormatInternalRowReaderContext.detectVectorColumnsFromMetadata(schema)
@@ -593,78 +435,6 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
}
}
- /**
- * Wraps an iterator to convert binary VECTOR columns back to typed arrays.
- * The read schema has BinaryType for vector columns; the target schema has
ArrayType.
- */
- private def wrapWithVectorConversion(iter: Iterator[InternalRow],
- readSchema: StructType,
- targetSchema: StructType,
- vectorCols: Map[Int,
HoodieSchema.Vector]): Iterator[InternalRow] = {
- val vectorProjection = UnsafeProjection.create(targetSchema)
- val javaVectorCols: java.util.Map[Integer, HoodieSchema.Vector] =
- vectorCols.map { case (k, v) => (Integer.valueOf(k), v) }.asJava
- val mapper = VectorConversionUtils.buildRowMapper(readSchema,
javaVectorCols, vectorProjection.apply(_))
- iter.map(mapper.apply(_))
- }
-
- // executor
- private def readBaseFile(file: PartitionedFile, parquetFileReader:
SparkColumnarFileReader, requestedSchema: StructType,
- remainingPartitionSchema: StructType,
fixedPartitionIndexes: Set[Int], readRequiredSchema: StructType,
- partitionSchema: StructType, outputSchema:
StructType, filters: Seq[Filter],
- storageConf: StorageConfiguration[Configuration]):
Iterator[InternalRow] = {
- // Detect vector columns and create modified schemas with BinaryType.
- // Each schema is detected independently because ordinals are relative to
the schema being
- // modified — outputSchema and requestedSchema may have vector columns at
different positions
- // than readRequiredSchema (e.g. when partition columns are interleaved).
- val (modifiedReadRequiredSchema, vectorCols) =
withVectorRewrite(readRequiredSchema)
- val hasVectors = vectorCols.nonEmpty
- val (modifiedOutputSchema, outputVectorCols) = if (hasVectors)
withVectorRewrite(outputSchema) else (outputSchema, Map.empty[Int,
HoodieSchema.Vector])
- val (modifiedRequestedSchema, _) = if (hasVectors)
withVectorRewrite(requestedSchema) else (requestedSchema, Map.empty[Int,
HoodieSchema.Vector])
-
- val rawIter = if (remainingPartitionSchema.fields.length ==
partitionSchema.fields.length) {
- //none of partition fields are read from the file, so the reader will do
the appending for us
- val iter = parquetFileReader.read(file, modifiedReadRequiredSchema,
partitionSchema, internalSchemaOpt, filters, storageConf,
tableSchemaAsMessageType)
- projectIfNeeded(iter, StructType(modifiedReadRequiredSchema.fields ++
partitionSchema.fields), modifiedOutputSchema)
- } else if (remainingPartitionSchema.fields.length == 0) {
- //we read all of the partition fields from the file
- val pfileUtils = sparkAdapter.getSparkPartitionedFileUtils
- //we need to modify the partitioned file so that the partition values
are empty
- val modifiedFile = pfileUtils.createPartitionedFile(InternalRow.empty,
pfileUtils.getPathFromPartitionedFile(file), file.start, file.length)
- val readSchema = StructType(modifiedReadRequiredSchema.fields ++
partitionSchema.fields)
- //and we pass an empty schema for the partition schema
- val iter = parquetFileReader.read(modifiedFile, readSchema, new
StructType(), internalSchemaOpt, filters, storageConf, tableSchemaAsMessageType)
- projectIfNeeded(iter, readSchema, modifiedOutputSchema)
- } else {
- //need to do an additional projection here. The case in mind is that
partition schema is "a,b,c" mandatoryFields is "a,c",
- //then we will read (dataSchema + a + c) and append b. So the final
schema will be (data schema + a + c +b)
- //but expected output is (data schema + a + b + c)
- val pfileUtils = sparkAdapter.getSparkPartitionedFileUtils
- val partitionValues = getFixedPartitionValues(file.partitionValues,
partitionSchema, fixedPartitionIndexes)
- val modifiedFile = pfileUtils.createPartitionedFile(partitionValues,
pfileUtils.getPathFromPartitionedFile(file), file.start, file.length)
- val iter = parquetFileReader.read(modifiedFile, modifiedRequestedSchema,
remainingPartitionSchema, internalSchemaOpt, filters, storageConf,
tableSchemaAsMessageType)
- projectIter(iter, StructType(modifiedRequestedSchema.fields ++
remainingPartitionSchema.fields), modifiedOutputSchema)
- }
-
- if (hasVectors) {
- // The raw iterator has BinaryType for vector columns; convert back to
ArrayType.
- // All branches above produce rows in modifiedOutputSchema: filter-only
columns from
- // readRequiredSchema are projected away by projectIfNeeded/projectIter.
- wrapWithVectorConversion(rawIter, modifiedOutputSchema, outputSchema,
outputVectorCols)
- } else {
- rawIter
- }
- }
-
- private def projectIter(iter: Iterator[Any], from: StructType, to:
StructType): Iterator[InternalRow] = {
- val unsafeProjection = generateUnsafeProjection(from, to)
- val batchProjection = ColumnarBatchUtils.generateProjection(from, to)
- iter.map {
- case ir: InternalRow => unsafeProjection(ir)
- case cb: ColumnarBatch => batchProjection(cb)
- }.asInstanceOf[Iterator[InternalRow]]
- }
-
/**
* A partition column whose name is a nested field path (e.g.
"nested_record.level") cannot be
* read from the data file as a flat top-level column, nor converted into a
top-level Avro field
@@ -673,22 +443,6 @@ class HoodieFileGroupReaderBasedFileFormat(tablePath:
String,
*/
private def isNestedPartitionField(name: String): Boolean =
name.contains(".")
- /**
- * Projects to `to` only when the read schema was augmented with filter-only
columns;
- * otherwise returns the iterator as is, preserving columnar batches.
- */
- private def projectIfNeeded(iter: Iterator[InternalRow], from: StructType,
to: StructType): Iterator[InternalRow] = {
- if (from.fieldNames.sameElements(to.fieldNames)) {
- iter
- } else {
- projectIter(iter, from, to)
- }
- }
-
- private def getFixedPartitionValues(allPartitionValues: InternalRow,
partitionSchema: StructType, fixedPartitionIndexes: Set[Int]): InternalRow = {
-
InternalRow.fromSeq(allPartitionValues.toSeq(partitionSchema).zipWithIndex.filter(p
=> fixedPartitionIndexes.contains(p._2)).map(p => p._1))
- }
-
override def inferSchema(sparkSession: SparkSession, options: Map[String,
String], files: Seq[FileStatus]): Option[StructType] = {
if (isMultipleBaseFileFormatsEnabled || hoodieFileFormat ==
HoodieFileFormat.PARQUET) {
ParquetUtils.inferSchema(sparkSession, options, files)
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderFunction.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderFunction.scala
new file mode 100644
index 000000000000..978111ac60d6
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/HoodieFileGroupReaderFunction.scala
@@ -0,0 +1,376 @@
+/*
+ * 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.execution.datasources.parquet
+
+import org.apache.hudi.{HoodiePartitionCDCFileGroupMapping,
HoodiePartitionFileSliceMapping, HoodieTableSchema, SparkAdapterSupport,
SparkFileFormatInternalRowReaderContext}
+import org.apache.hudi.cdc.{CDCFileGroupIterator, HoodieCDCFileGroupSplit,
HoodieCDCFileIndex}
+import org.apache.hudi.common.config.{HoodieReaderConfig, TypedProperties}
+import org.apache.hudi.common.fs.FSUtils
+import org.apache.hudi.common.schema.HoodieSchema
+import org.apache.hudi.common.schema.internal.InternalSchema
+import org.apache.hudi.common.table.{HoodieTableMetaClient,
ParquetTableSchemaResolver}
+import org.apache.hudi.common.table.log.InstantRange
+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.{ConfigUtils, Option => HOption}
+import org.apache.hudi.common.util.collection.ClosableIterator
+import org.apache.hudi.data.CloseableIteratorListener
+import
org.apache.hudi.io.storage.HoodieSparkParquetReader.ENABLE_LOGICAL_TIMESTAMP_REPAIR
+import org.apache.hudi.io.storage.VectorConversionUtils
+import org.apache.hudi.storage.StorageConfiguration
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration
+
+import org.apache.hadoop.conf.Configuration
+import org.apache.parquet.schema.MessageType
+import org.apache.spark.SparkEnv
+import org.apache.spark.broadcast.Broadcast
+import
org.apache.spark.sql.HoodieCatalystExpressionUtils.generateUnsafeProjection
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{JoinedRow, UnsafeProjection}
+import org.apache.spark.sql.execution.datasources.{PartitionedFile,
SparkColumnarFileReader, SparkSchemaTransformUtils}
+import org.apache.spark.sql.sources.Filter
+import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnarBatchUtils}
+import org.apache.spark.util.{SerializableConfiguration, Utils}
+
+import java.io.Closeable
+import java.nio.ByteBuffer
+
+import scala.collection.JavaConverters.mapAsJavaMapConverter
+import scala.reflect.ClassTag
+
+/**
+ * Read-only state of one scan of [[HoodieFileGroupReaderBasedFileFormat]],
built on the driver and shared by every
+ * task of an executor through [[HoodieFileGroupReaderFunction]]. Executors
only fill thread-safe lazy caches in it;
+ * per-file state such as reader properties is copied before use.
+ */
+private[parquet] class HoodieFileGroupReadState(val metaClient:
HoodieTableMetaClient,
+ val tableSchema:
HoodieTableSchema,
+ val queryTimestamp: String,
+ val readerProps:
TypedProperties,
+ val cdcProps: TypedProperties,
+ val dataSchema: HoodieSchema,
+ val requestedSchema:
HoodieSchema,
+ val internalSchemaOpt:
HOption[InternalSchema],
+ val instantRangeOpt:
HOption[InstantRange],
+ val shouldUseRecordPosition:
Boolean,
+ val isCount: Boolean,
+ val filters: Seq[Filter],
+ val requiredFilters:
Seq[Filter],
+ val requiredSchema:
StructType,
+ val partitionSchema:
StructType,
+ val remainingPartitionSchema:
StructType,
+ val fixedPartitionIndexes:
Set[Int],
+ val outputSchema: StructType,
+ val projectionInputSchema:
StructType,
+ val baseFileReadSchemas:
BaseFileReadSchemas) extends Serializable {
+
+ /**
+ * Parquet form of the table schema for base file reads. The conversion
reads Hadoop's default resources, so it is
+ * done once per executor rather than per task or file.
+ */
+ @transient lazy val tableSchemaAsMessageType: HOption[MessageType] =
+
HOption.ofNullable(ParquetTableSchemaResolver.convertAvroSchemaToParquet(tableSchema.schema,
new Configuration()))
+}
+
+/**
+ * Base file read schemas with VECTOR columns rewritten to BinaryType, plus
the ordinals of those columns.
+ */
+private[parquet] case class BaseFileReadSchemas(readRequiredSchema: StructType,
+ readVectorColumns: Map[Int,
HoodieSchema.Vector],
+ outputSchema: StructType,
+ outputVectorColumns: Map[Int,
HoodieSchema.Vector],
+ requestedSchema: StructType)
+
+/**
+ * Holds a value as Java-serialized bytes and deserializes it at most once per
JVM instance of the holder.
+ *
+ * <p>Broadcasting this holder instead of the value keeps the broadcast
independent of `spark.serializer`: Kryo
+ * would otherwise serialize the value field by field and ignore the custom
Java serialization of types such as
+ * [[HoodieSchema]] and [[HadoopStorageConfiguration]].
+ */
+private[parquet] class JavaSerializedValue[T: ClassTag] private(bytes:
Array[Byte]) extends Serializable {
+
+ @transient lazy val value: T = SparkEnv.get.closureSerializer.newInstance()
+ .deserialize[T](ByteBuffer.wrap(bytes), Utils.getContextOrSparkClassLoader)
+}
+
+private[parquet] object JavaSerializedValue {
+
+ def apply[T: ClassTag](value: T): JavaSerializedValue[T] = {
+ val buffer = SparkEnv.get.closureSerializer.newInstance().serialize(value)
+ val bytes = new Array[Byte](buffer.remaining())
+ buffer.get(bytes)
+ new JavaSerializedValue[T](bytes)
+ }
+}
+
+/**
+ * The per-file read function returned by
[[HoodieFileGroupReaderBasedFileFormat.buildReaderWithPartitionValues]].
+ *
+ * <p>Spark deserializes this function once per task, so it holds only
broadcast handles. The scan state behind
+ * `broadcastedState` is deserialized once per executor.
+ */
+private[parquet] class HoodieFileGroupReaderFunction(baseFileReader:
Broadcast[SparkColumnarFileReader],
+ fileGroupBaseFileReader:
Broadcast[SparkColumnarFileReader],
+ storageConf:
Broadcast[SerializableConfiguration],
+ broadcastedState:
Broadcast[JavaSerializedValue[HoodieFileGroupReadState]])
+ extends (PartitionedFile => Iterator[InternalRow]) with Serializable {
+
+ import HoodieFileGroupReaderFunction._
+
+ private def sparkAdapter = SparkAdapterSupport.sparkAdapter
+
+ override def apply(file: PartitionedFile): Iterator[InternalRow] = {
+ val state = broadcastedState.value.value
+ val conf = new HadoopStorageConfiguration(storageConf.value.value)
+ val iter = file.partitionValues match {
+ // Snapshot or incremental queries.
+ case fileSliceMapping: HoodiePartitionFileSliceMapping =>
+ val fileGroupName = FSUtils.getFileIdFromFilePath(sparkAdapter
+ .getSparkPartitionedFileUtils.getPathFromPartitionedFile(file))
+ fileSliceMapping.getSlice(fileGroupName) match {
+ case Some(fileSlice) if !state.isCount &&
(state.requiredSchema.nonEmpty || fileSlice.getLogFiles.findAny().isPresent) =>
+ val tableConfig = state.metaClient.getTableConfig
+ // requiredFilters preserve Spark's row-level filtering semantics,
while instantRangeOpt
+ // keeps out-of-range records from participating in the file-group
merge itself.
+ val readerContext = new SparkFileFormatInternalRowReaderContext(
+ fileGroupBaseFileReader.value, state.filters,
state.requiredFilters, conf, tableConfig,
+ sparkRequiredSchema = Some(state.requiredSchema),
instantRangeOpt = state.instantRangeOpt)
+
readerContext.enableLogicalTimestampFieldRepair(conf.getBoolean(ENABLE_LOGICAL_TIMESTAMP_REPAIR,
true))
+ // The merger factory writes the payload class into the props it
is given, so each file gets its own copy
+ val props = TypedProperties.copy(state.readerProps)
+ val baseFileLength = if (fileSlice.getBaseFile.isPresent) {
+ fileSlice.getBaseFile.get.getFileSize
+ } else {
+ 0
+ }
+ val reader: HoodieRecordReader[InternalRow] =
+ if (LsmReaderUtils.shouldUseLsmReader(
+ tableConfig,
+ ConfigUtils.getStringWithAltKeys(props,
HoodieReaderConfig.MERGE_TYPE, true))) {
+ HoodieLsmFileGroupReader.builder[InternalRow]()
+ .withReaderContext(readerContext)
+ .withHoodieTableMetaClient(state.metaClient)
+ .withLatestCommitTime(state.queryTimestamp)
+ .withBaseFileOption(fileSlice.getBaseFile)
+ .withLogFiles(fileSlice.getLogFiles)
+ .withPartitionPath(fileSlice.getPartitionPath)
+ .withDataSchema(state.dataSchema)
+ .withRequestedSchema(state.requestedSchema)
+ .withInternalSchemaOpt(state.internalSchemaOpt)
+ .withProps(props)
+ .withStart(file.start)
+ .withLength(baseFileLength)
+ .build()
+ } else {
+ HoodieFileGroupReader.builder[InternalRow]()
+ .withReaderContext(readerContext)
+ .withHoodieTableMetaClient(state.metaClient)
+ .withLatestCommitTime(state.queryTimestamp)
+ .withBaseFileOption(fileSlice.getBaseFile)
+ .withLogFiles(fileSlice.getLogFiles)
+ .withPartitionPath(fileSlice.getPartitionPath)
+ .withDataSchema(state.dataSchema)
+ .withRequestedSchema(state.requestedSchema)
+ .withInternalSchemaOpt(state.internalSchemaOpt)
+ .withProps(props)
+ .withStart(file.start)
+ .withLength(baseFileLength)
+ .withShouldUseRecordPosition(state.shouldUseRecordPosition)
+ .build()
+ }
+ // Append partition values to rows and project to output schema
+ appendPartitionAndProject(
+ reader.getClosableIterator,
+ state.projectionInputSchema,
+ state.remainingPartitionSchema,
+ state.outputSchema,
+ fileSliceMapping.getPartitionValues,
+ state.fixedPartitionIndexes)
+
+ case _ =>
+ readBaseFile(file, state, conf)
+ }
+ // CDC queries.
+ case cdcFileGroupMapping: HoodiePartitionCDCFileGroupMapping =>
+ // The CDC iterator hands its props to the merger factory, which
writes the payload class into them
+ new CDCFileGroupIterator(
+ HoodieCDCFileGroupSplit(cdcFileGroupMapping.getFileSplits().toArray),
+ state.metaClient,
+ conf,
+ fileGroupBaseFileReader.value,
+ state.tableSchema,
+ HoodieCDCFileIndex.FULL_CDC_SPARK_SCHEMA,
+ state.requiredSchema,
+ TypedProperties.copy(state.cdcProps))
+
+ case _ =>
+ readBaseFile(file, state, conf)
+ }
+ CloseableIteratorListener.addListener(iter)
+ }
+
+ private def readBaseFile(file: PartitionedFile,
+ state: HoodieFileGroupReadState,
+ conf: StorageConfiguration[Configuration]):
Iterator[InternalRow] = {
+ val schemas = state.baseFileReadSchemas
+ val hasVectors = schemas.readVectorColumns.nonEmpty
+ val parquetFileReader = baseFileReader.value
+ val filters = state.filters ++ state.requiredFilters
+ val partitionSchema = state.partitionSchema
+ val remainingPartitionSchema = state.remainingPartitionSchema
+
+ val rawIter = if (remainingPartitionSchema.fields.length ==
partitionSchema.fields.length) {
+ //none of partition fields are read from the file, so the reader will do
the appending for us
+ val iter = parquetFileReader.read(file, schemas.readRequiredSchema,
partitionSchema, state.internalSchemaOpt, filters, conf,
+ state.tableSchemaAsMessageType)
+ projectIfNeeded(iter, StructType(schemas.readRequiredSchema.fields ++
partitionSchema.fields), schemas.outputSchema)
+ } else if (remainingPartitionSchema.fields.length == 0) {
+ //we read all of the partition fields from the file
+ val pfileUtils = sparkAdapter.getSparkPartitionedFileUtils
+ //we need to modify the partitioned file so that the partition values
are empty
+ val modifiedFile = pfileUtils.createPartitionedFile(InternalRow.empty,
pfileUtils.getPathFromPartitionedFile(file), file.start, file.length)
+ val readSchema = StructType(schemas.readRequiredSchema.fields ++
partitionSchema.fields)
+ //and we pass an empty schema for the partition schema
+ val iter = parquetFileReader.read(modifiedFile, readSchema, new
StructType(), state.internalSchemaOpt, filters, conf,
+ state.tableSchemaAsMessageType)
+ projectIfNeeded(iter, readSchema, schemas.outputSchema)
+ } else {
+ //need to do an additional projection here. The case in mind is that
partition schema is "a,b,c" mandatoryFields is "a,c",
+ //then we will read (dataSchema + a + c) and append b. So the final
schema will be (data schema + a + c +b)
+ //but expected output is (data schema + a + b + c)
+ val pfileUtils = sparkAdapter.getSparkPartitionedFileUtils
+ val partitionValues = getFixedPartitionValues(file.partitionValues,
partitionSchema, state.fixedPartitionIndexes)
+ val modifiedFile = pfileUtils.createPartitionedFile(partitionValues,
pfileUtils.getPathFromPartitionedFile(file), file.start, file.length)
+ val iter = parquetFileReader.read(modifiedFile, schemas.requestedSchema,
remainingPartitionSchema, state.internalSchemaOpt, filters, conf,
+ state.tableSchemaAsMessageType)
+ projectIter(iter, StructType(schemas.requestedSchema.fields ++
remainingPartitionSchema.fields), schemas.outputSchema)
+ }
+
+ if (hasVectors) {
+ // The raw iterator has BinaryType for vector columns; convert back to
ArrayType.
+ // All branches above produce rows in the rewritten output schema:
filter-only columns from
+ // readRequiredSchema are projected away by projectIfNeeded/projectIter.
+ wrapWithVectorConversion(rawIter, schemas.outputSchema,
state.outputSchema, schemas.outputVectorColumns)
+ } else {
+ rawIter
+ }
+ }
+}
+
+private[parquet] object HoodieFileGroupReaderFunction {
+
+ private def appendPartitionAndProject(iter: ClosableIterator[InternalRow],
+ inputSchema: StructType,
+ partitionSchema: StructType,
+ to: StructType,
+ partitionValues: InternalRow,
+ fixedPartitionIndexes: Set[Int]):
Iterator[InternalRow] = {
+ if (partitionSchema.isEmpty) {
+ //'inputSchema' and 'to' should be the same so the projection will just
be an identity func
+ projectSchema(iter, inputSchema, to)
+ } else {
+ val fixedPartitionValues = if (partitionSchema.length ==
partitionValues.numFields) {
+ //need to append all of the partition fields
+ partitionValues
+ } else {
+ //some partition fields read from file, some were not
+ getFixedPartitionValues(partitionValues, partitionSchema,
fixedPartitionIndexes)
+ }
+ val unsafeProjection =
generateOutputProjection(StructType(inputSchema.fields ++
partitionSchema.fields), to)
+ val joinedRow = new JoinedRow()
+ makeCloseableFileGroupMappingRecordIterator(iter, d =>
unsafeProjection(joinedRow(d, fixedPartitionValues)))
+ }
+ }
+
+ private def projectSchema(iter: ClosableIterator[InternalRow],
+ from: StructType,
+ to: StructType): Iterator[InternalRow] = {
+ val unsafeProjection = generateOutputProjection(from, to)
+ makeCloseableFileGroupMappingRecordIterator(iter, d => unsafeProjection(d))
+ }
+
+ /**
+ * The scan's output projection. Stays the by-name top-level projection
unless a column comes out of the
+ * reader with nested fields Spark did not ask for (see
`projectionInputSchema` in
+ * buildReaderWithPartitionValues), in which case those are dropped by name
at every depth.
+ */
+ private def generateOutputProjection(from: StructType, to: StructType):
UnsafeProjection = {
+ val hasWiderNestedInput = to.fields.exists { f =>
+ from.getFieldIndex(f.name).exists(i =>
SparkSchemaTransformUtils.needsNestedPruning(from.fields(i).dataType,
f.dataType))
+ }
+ if (hasWiderNestedInput) {
+ SparkSchemaTransformUtils.generateNestedPruningProjection(from, to)
+ } else {
+ generateUnsafeProjection(from, to)
+ }
+ }
+
+ private def
makeCloseableFileGroupMappingRecordIterator(closeableFileGroupRecordIterator:
ClosableIterator[InternalRow],
+ mappingFunction:
Function[InternalRow, InternalRow]): Iterator[InternalRow] = {
+ CloseableIteratorListener.addListener(closeableFileGroupRecordIterator)
+ new Iterator[InternalRow] with Closeable {
+ override def hasNext: Boolean = closeableFileGroupRecordIterator.hasNext
+
+ override def next(): InternalRow =
mappingFunction(closeableFileGroupRecordIterator.next())
+
+ override def close(): Unit = closeableFileGroupRecordIterator.close()
+ }
+ }
+
+ /**
+ * Wraps an iterator to convert binary VECTOR columns back to typed arrays.
+ * The read schema has BinaryType for vector columns; the target schema has
ArrayType.
+ */
+ private def wrapWithVectorConversion(iter: Iterator[InternalRow],
+ readSchema: StructType,
+ targetSchema: StructType,
+ vectorCols: Map[Int,
HoodieSchema.Vector]): Iterator[InternalRow] = {
+ val vectorProjection = UnsafeProjection.create(targetSchema)
+ val javaVectorCols: java.util.Map[Integer, HoodieSchema.Vector] =
+ vectorCols.map { case (k, v) => (Integer.valueOf(k), v) }.asJava
+ val mapper = VectorConversionUtils.buildRowMapper(readSchema,
javaVectorCols, vectorProjection.apply(_))
+ iter.map(mapper.apply(_))
+ }
+
+ private def projectIter(iter: Iterator[Any], from: StructType, to:
StructType): Iterator[InternalRow] = {
+ val unsafeProjection = generateUnsafeProjection(from, to)
+ val batchProjection = ColumnarBatchUtils.generateProjection(from, to)
+ iter.map {
+ case ir: InternalRow => unsafeProjection(ir)
+ case cb: ColumnarBatch => batchProjection(cb)
+ }.asInstanceOf[Iterator[InternalRow]]
+ }
+
+ /**
+ * Projects to `to` only when the read schema was augmented with filter-only
columns;
+ * otherwise returns the iterator as is, preserving columnar batches.
+ */
+ private def projectIfNeeded(iter: Iterator[InternalRow], from: StructType,
to: StructType): Iterator[InternalRow] = {
+ if (from.fieldNames.sameElements(to.fieldNames)) {
+ iter
+ } else {
+ projectIter(iter, from, to)
+ }
+ }
+
+ private def getFixedPartitionValues(allPartitionValues: InternalRow,
partitionSchema: StructType, fixedPartitionIndexes: Set[Int]): InternalRow = {
+
InternalRow.fromSeq(allPartitionValues.toSeq(partitionSchema).zipWithIndex.filter(p
=> fixedPartitionIndexes.contains(p._2)).map(p => p._1))
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestReadOptionsDoNotOverrideTableConfig.java
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestReadOptionsDoNotOverrideTableConfig.java
new file mode 100644
index 000000000000..a0ef6fde8ddf
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/java/org/apache/hudi/functional/TestReadOptionsDoNotOverrideTableConfig.java
@@ -0,0 +1,247 @@
+/*
+ * 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.DataSourceReadOptions;
+import org.apache.hudi.DataSourceWriteOptions;
+import org.apache.hudi.HoodieFileIndex;
+import org.apache.hudi.SparkAdapterSupport$;
+import org.apache.hudi.common.config.RecordMergeMode;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.model.OverwriteWithLatestAvroPayload;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.read.CustomPayloadForTesting;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.testutils.SparkClientFunctionalTestHarness;
+
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.RowFactory;
+import org.apache.spark.sql.SaveMode;
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan;
+import org.apache.spark.sql.execution.datasources.HadoopFsRelation;
+import org.apache.spark.sql.execution.datasources.LogicalRelation;
+import org.apache.spark.sql.types.DataTypes;
+import org.apache.spark.sql.types.StructField;
+import org.apache.spark.sql.types.StructType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import scala.collection.JavaConverters;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A snapshot read merges base and log records with the merge mode and
ordering fields persisted in
+ * the table config. Read options that happen to share a table config key must
not change that:
+ * the persisted config is authoritative, and a read must not return different
rows because an
+ * option was passed, or depending on which files a task happened to read
first.
+ *
+ * <p>The table orders by {@code ts}, through {@link
RecordMergeMode#EVENT_TIME_ORDERING} or through a
+ * persisted custom payload. Every key is first written with {@code ts = 10}
and value {@code A}, then
+ * updated with a lower {@code ts = 5} and value {@code B}, which the ordering
must discard, so every read
+ * must return {@code A}. All file groups are packed into a single task, so
that later file
+ * groups of the task are read after earlier ones have been set up. The table
config of the relation
+ * must also be left as persisted after the read.
+ */
+@Tag("functional")
+class TestReadOptionsDoNotOverrideTableConfig extends
SparkClientFunctionalTestHarness {
+
+ private static final int NUM_PARTITIONS = 4;
+ private static final int KEYS_PER_PARTITION = 5;
+
+ private static final StructType SCHEMA = DataTypes.createStructType(new
StructField[] {
+ DataTypes.createStructField("key", DataTypes.StringType, false),
+ DataTypes.createStructField("part", DataTypes.StringType, false),
+ DataTypes.createStructField("ts", DataTypes.LongType, false),
+ DataTypes.createStructField("other_ts", DataTypes.LongType, false),
+ DataTypes.createStructField("value", DataTypes.StringType, true)});
+
+ private static final String[] PACKING_CONFIGS = {
+ "spark.sql.files.minPartitionNum", "spark.sql.files.openCostInBytes"};
+
+ private final Map<String, String> savedSessionConfs = new HashMap<>();
+
+ @BeforeEach
+ void packFilesIntoOneTask() {
+ for (String conf : PACKING_CONFIGS) {
+ if (spark().conf().contains(conf)) {
+ savedSessionConfs.put(conf, spark().conf().get(conf));
+ }
+ }
+ spark().conf().set("spark.sql.files.minPartitionNum", "1");
+ spark().conf().set("spark.sql.files.openCostInBytes", "1");
+ }
+
+ @AfterEach
+ void restoreSessionConfs() {
+ for (String conf : PACKING_CONFIGS) {
+ if (savedSessionConfs.containsKey(conf)) {
+ spark().conf().set(conf, savedSessionConfs.get(conf));
+ } else {
+ spark().conf().unset(conf);
+ }
+ }
+ }
+
+ @Test
+ void testMergeModeReadOptionDoesNotOverrideTableConfig() {
+ String basePath = writeEventTimeOrderingTable();
+ assertAllRowsKeepFirstValue(read(basePath, Collections.emptyMap()), "a
read without options");
+ Dataset<Row> df = read(basePath,
Collections.singletonMap(HoodieTableConfig.RECORD_MERGE_MODE.key(),
RecordMergeMode.COMMIT_TIME_ORDERING.name()));
+ assertAllRowsKeepFirstValue(df, "a read with " +
HoodieTableConfig.RECORD_MERGE_MODE.key() + "=" +
RecordMergeMode.COMMIT_TIME_ORDERING);
+ assertEventTimeOrderingTableConfig(relationTableConfig(df));
+ }
+
+ @Test
+ void testOrderingFieldsReadOptionDoesNotOverrideTableConfig() {
+ String basePath = writeEventTimeOrderingTable();
+ // other_ts orders the update after the insert; the persisted ordering
field ts does not.
+ Dataset<Row> df = read(basePath,
Collections.singletonMap(HoodieTableConfig.ORDERING_FIELDS.key(), "other_ts"));
+ assertAllRowsKeepFirstValue(df, "a read with " +
HoodieTableConfig.ORDERING_FIELDS.key() + "=other_ts");
+ assertEventTimeOrderingTableConfig(relationTableConfig(df));
+ }
+
+ /**
+ * The persisted payload class is looked up on a different path than the
merge mode and ordering fields
+ * (see {@code ConfigUtils.getMergeProps}), so it gets its own table: a
custom merge with
+ * {@link CustomPayloadForTesting}, which keeps the record with the higher
{@code ts}.
+ */
+ @Test
+ void testPayloadClassReadOptionDoesNotOverrideTableConfig() {
+ Map<String, String> mergeOptions = new HashMap<>();
+ mergeOptions.put(HoodieWriteConfig.RECORD_MERGE_MODE.key(),
RecordMergeMode.CUSTOM.name());
+ mergeOptions.put(HoodieWriteConfig.WRITE_PAYLOAD_CLASS_NAME.key(),
CustomPayloadForTesting.class.getName());
+ String basePath = writeTable(mergeOptions);
+ assertEquals(CustomPayloadForTesting.class.getName(),
tableConfig(basePath).getPayloadClass());
+ assertAllRowsKeepFirstValue(read(basePath, Collections.emptyMap()), "a
read without options");
+ // Overwriting with the latest write would keep the ts=5 update.
+ String overwritePayload = OverwriteWithLatestAvroPayload.class.getName();
+ Dataset<Row> df = read(basePath,
Collections.singletonMap(HoodieTableConfig.PAYLOAD_CLASS_NAME.key(),
overwritePayload));
+ assertAllRowsKeepFirstValue(df, "a read with " +
HoodieTableConfig.PAYLOAD_CLASS_NAME.key() + "=" + overwritePayload);
+ assertEquals(CustomPayloadForTesting.class.getName(),
relationTableConfig(df).getPayloadClass());
+ }
+
+ private String writeEventTimeOrderingTable() {
+ String basePath =
writeTable(Collections.singletonMap(HoodieWriteConfig.RECORD_MERGE_MODE.key(),
RecordMergeMode.EVENT_TIME_ORDERING.name()));
+ assertEventTimeOrderingTableConfig(tableConfig(basePath));
+ return basePath;
+ }
+
+ private String writeTable(Map<String, String> mergeOptions) {
+ String basePath = basePath();
+ Map<String, String> options = new HashMap<>();
+ options.put(HoodieTableConfig.NAME.key(), "merge_config_from_table");
+ options.put(DataSourceWriteOptions.TABLE_TYPE().key(),
HoodieTableType.MERGE_ON_READ.name());
+ options.put(DataSourceWriteOptions.RECORDKEY_FIELD().key(), "key");
+ options.put(DataSourceWriteOptions.PARTITIONPATH_FIELD().key(), "part");
+ options.put(DataSourceWriteOptions.ORDERING_FIELDS().key(), "ts");
+ options.putAll(mergeOptions);
+ options.put("hoodie.insert.shuffle.parallelism", "2");
+ options.put("hoodie.upsert.shuffle.parallelism", "2");
+ write(rows(10L, 1L, "A"), options, basePath);
+ write(rows(5L, 2L, "B"), options, basePath);
+ assertTrue(countLogFiles(tempDir) >= NUM_PARTITIONS, "Every file group
should have a log file to merge");
+ return basePath;
+ }
+
+ private HoodieTableConfig tableConfig(String basePath) {
+ return
HoodieTableMetaClient.builder().setBasePath(basePath).setConf(storageConf()).build().getTableConfig();
+ }
+
+ private static void assertEventTimeOrderingTableConfig(HoodieTableConfig
tableConfig) {
+ assertEquals(RecordMergeMode.EVENT_TIME_ORDERING,
tableConfig.getRecordMergeMode());
+ assertEquals("ts", tableConfig.getOrderingFieldsStr().orElse(null));
+ }
+
+ private Dataset<Row> read(String basePath, Map<String, String> options) {
+ Dataset<Row> df = spark().read().format("hudi")
+ .option(DataSourceReadOptions.QUERY_TYPE().key(),
DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL())
+ .options(options)
+ .load(basePath);
+ assertEquals(1, df.rdd().getNumPartitions(), "All file groups should be
read by a single task");
+ return df;
+ }
+
+ private static void assertAllRowsKeepFirstValue(Dataset<Row> df, String
description) {
+ List<Row> rows = df.select("key", "value", "ts").collectAsList();
+ assertEquals(NUM_PARTITIONS * KEYS_PER_PARTITION, rows.size(),
description);
+ List<String> overridden = rows.stream()
+ .filter(row -> !"A".equals(row.getString(1)))
+ .map(row -> row.getString(0) + "=" + row.getString(1) + "@ts" +
row.getLong(2))
+ .sorted()
+ .collect(Collectors.toList());
+ assertTrue(overridden.isEmpty(), "With the ordering on ts persisted in the
table config, "
+ + description + " must keep the ts=10 value A for every key, but these
keys returned the lower-ts update: "
+ + overridden);
+ }
+
+ /**
+ * The table config of the relation's meta client, which the reader state is
built from on the driver; the read
+ * options must not have been written into it.
+ */
+ private static HoodieTableConfig relationTableConfig(Dataset<Row> df) {
+ LogicalPlan relationPlan =
JavaConverters.seqAsJavaList(df.queryExecution().optimizedPlan().collectLeaves()).stream()
+ .filter(plan -> plan instanceof LogicalRelation)
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("No relation in the plan of the
read"));
+ HadoopFsRelation relation = (HadoopFsRelation) ((LogicalRelation)
relationPlan).relation();
+ return ((HoodieFileIndex)
relation.location()).metaClient().getTableConfig();
+ }
+
+ private static List<Row> rows(long ts, long otherTs, String value) {
+ return IntStream.range(0, NUM_PARTITIONS * KEYS_PER_PARTITION)
+ .mapToObj(i -> RowFactory.create(String.format("key%02d", i), "p" + (i
% NUM_PARTITIONS), ts, otherTs, value))
+ .collect(Collectors.toList());
+ }
+
+ private void write(List<Row> rows, Map<String, String> options, String
basePath) {
+ spark().createDataset(rows,
SparkAdapterSupport$.MODULE$.sparkAdapter().getCatalystExpressionUtils().getEncoder(SCHEMA))
+ .write()
+ .format("hudi")
+ .options(options)
+ .mode(SaveMode.Append)
+ .save(basePath);
+ }
+
+ private static long countLogFiles(Path tableDir) {
+ try (Stream<Path> files = Files.walk(tableDir)) {
+ return files.filter(p -> !p.toString().contains("/.hoodie/") &&
p.getFileName().toString().contains(".log.")).count();
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+}
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieFileGroupReaderFunction.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieFileGroupReaderFunction.scala
new file mode 100644
index 000000000000..27e2c21eb123
--- /dev/null
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/TestHoodieFileGroupReaderFunction.scala
@@ -0,0 +1,112 @@
+/*
+ * 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.execution.datasources.parquet
+
+import org.apache.hudi.{DataSourceReadOptions, DataSourceWriteOptions}
+import org.apache.hudi.common.model.HoodieTableType
+import org.apache.hudi.common.table.{HoodieTableConfig, HoodieTableMetaClient}
+import
org.apache.hudi.common.testutils.HoodieTestDataGenerator.recordsToStrings
+import org.apache.hudi.config.HoodieWriteConfig
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration
+import org.apache.hudi.testutils.HoodieSparkClientTestBase
+
+import org.apache.spark.sql.SaveMode
+import org.apache.spark.sql.execution.datasources.{HadoopFsRelation,
LogicalRelation}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertTrue}
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.EnumSource
+
+import java.io.{ByteArrayOutputStream, ObjectOutputStream, OutputStream}
+
+import scala.collection.JavaConverters._
+import scala.collection.mutable
+
+/**
+ * Spark Java-serializes the function returned by
[[HoodieFileGroupReaderBasedFileFormat.buildReaderWithPartitionValues]]
+ * into every task. It must hold only broadcast handles, not the file format,
a meta client or a Hadoop configuration.
+ */
+class TestHoodieFileGroupReaderFunction extends HoodieSparkClientTestBase {
+
+ private val maxSerializedFunctionBytes = 16 * 1024
+
+ private val classesNotSerializedWithFunction: Seq[Class[_]] = Seq(
+ classOf[HoodieTableMetaClient],
+ classOf[HoodieFileGroupReaderBasedFileFormat],
+ classOf[HadoopStorageConfiguration],
+ classOf[org.apache.hadoop.conf.Configuration])
+
+ @ParameterizedTest
+ @EnumSource(value = classOf[HoodieTableType])
+ def testReaderFunctionHoldsNoDriverState(tableType: HoodieTableType): Unit =
{
+ val opts = Map(
+ HoodieWriteConfig.TBL_NAME.key -> "hoodie_test",
+ DataSourceWriteOptions.TABLE_TYPE.key -> tableType.name(),
+ DataSourceWriteOptions.RECORDKEY_FIELD.key -> "_row_key",
+ DataSourceWriteOptions.PARTITIONPATH_FIELD.key -> "partition",
+ HoodieTableConfig.ORDERING_FIELDS.key -> "timestamp",
+ "hoodie.insert.shuffle.parallelism" -> "2",
+ "hoodie.upsert.shuffle.parallelism" -> "2")
+ val inserts = recordsToStrings(dataGen.generateInserts("001",
20)).asScala.toSeq
+ sparkSession.read.json(sparkSession.sparkContext.parallelize(inserts, 2))
+
.write.format("hudi").options(opts).mode(SaveMode.Overwrite).save(basePath)
+ val updates = recordsToStrings(dataGen.generateUpdates("002",
10)).asScala.toSeq
+ sparkSession.read.json(sparkSession.sparkContext.parallelize(updates, 2))
+ .write.format("hudi").options(opts).mode(SaveMode.Append).save(basePath)
+
+ val df = sparkSession.read.format("hudi")
+ .option(DataSourceReadOptions.QUERY_TYPE.key,
DataSourceReadOptions.QUERY_TYPE_SNAPSHOT_OPT_VAL)
+ .load(basePath)
+ assertEquals(20, df.count())
+ val relation = df.queryExecution.optimizedPlan.collectFirst {
+ case l: LogicalRelation => l.relation.asInstanceOf[HadoopFsRelation]
+ }.get
+ val format =
relation.fileFormat.asInstanceOf[HoodieFileGroupReaderBasedFileFormat]
+ // Spark's scan passes the batch decision it planned with
("returning_batch" is FileFormat.OPTION_RETURNING_BATCH).
+ val returningBatch = format.supportBatch(sparkSession, relation.schema)
+ val options = relation.options + ("returning_batch" ->
returningBatch.toString)
+ val readerFunction = format.buildReaderWithPartitionValues(sparkSession,
relation.dataSchema, relation.partitionSchema,
+ relation.dataSchema, Seq.empty, options,
sparkSession.sessionState.newHadoopConfWithOptions(options))
+ assertTrue(readerFunction.isInstanceOf[HoodieFileGroupReaderFunction],
+ s"Unexpected reader function ${readerFunction.getClass.getName}")
+
+ val bytes = new ByteArrayOutputStream()
+ val out = new ClassRecordingObjectOutputStream(bytes)
+ out.writeObject(readerFunction)
+ out.close()
+ val captured = classesNotSerializedWithFunction.flatMap(forbidden =>
+ out.classes.filter(c => forbidden.isAssignableFrom(c)).map(_.getName))
+ assertTrue(captured.isEmpty, s"The serialized reader function must not
contain $captured")
+ assertTrue(bytes.size() < maxSerializedFunctionBytes,
+ s"The serialized reader function takes ${bytes.size()} bytes, over the
budget of $maxSerializedFunctionBytes")
+ }
+
+ /**
+ * Records the class of every object written to the stream.
+ */
+ private class ClassRecordingObjectOutputStream(out: OutputStream) extends
ObjectOutputStream(out) {
+ val classes: mutable.Set[Class[_]] = mutable.LinkedHashSet[Class[_]]()
+ enableReplaceObject(true)
+
+ override def replaceObject(obj: AnyRef): AnyRef = {
+ if (obj != null) {
+ classes += obj.getClass
+ }
+ obj
+ }
+ }
+}