Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,12 @@ import org.apache.spark.sql.connector.catalog.TableCapability.{BATCH_READ, BATCH
import org.apache.spark.sql.connector.expressions.Transform
import org.apache.spark.sql.connector.read.ScanBuilder
import org.apache.spark.sql.connector.write.{LogicalWriteInfo, WriteBuilder}
import org.apache.spark.sql.execution.datasources.v2.orc.OrcScanBuilder
import org.apache.spark.sql.execution.datasources.v2.parquet.ParquetScanBuilder
import org.apache.spark.sql.hive.kyuubi.connector.HiveBridgeHelper.{BucketSpecHelper, LogicalExpressions}
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.util.CaseInsensitiveStringMap

import org.apache.kyuubi.spark.connector.hive.KyuubiHiveConnectorConf.{READ_CONVERT_METASTORE_ORC, READ_CONVERT_METASTORE_PARQUET}
import org.apache.kyuubi.spark.connector.hive.read.{HiveCatalogFileIndex, HiveScanBuilder}
import org.apache.kyuubi.spark.connector.hive.read.{HiveCatalogFileIndex, HiveScanBuilder, KyuubiOrcScanBuilder, KyuubiParquetScanBuilder}
import org.apache.kyuubi.spark.connector.hive.write.HiveWriteBuilder

case class HiveTable(
Expand Down Expand Up @@ -109,10 +107,24 @@ case class HiveTable(
override def newScanBuilder(options: CaseInsensitiveStringMap): ScanBuilder = {
convertedProvider match {
case Some("ORC") if sparkSession.sessionState.conf.getConf(READ_CONVERT_METASTORE_ORC) =>
OrcScanBuilder(sparkSession, fileIndex, schema, dataSchema, options)
new KyuubiOrcScanBuilder(
sparkSession,
fileIndex,
schema,
dataSchema,
options,
catalogTable,
hiveTableCatalog)
case Some("PARQUET")
if sparkSession.sessionState.conf.getConf(READ_CONVERT_METASTORE_PARQUET) =>
ParquetScanBuilder(sparkSession, fileIndex, schema, dataSchema, options)
new KyuubiParquetScanBuilder(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIGH · ecosystem compatibility

KyuubiParquetScan / KyuubiOrcScan extend FileScan directly rather than subclassing ParquetScan / OrcScan, so native engines that identify the file format by scan type stop recognizing them. Gluten's getSubstraitReadFileFormatV2 is a getClass.getSimpleName string match; it now falls through to UnknownFormat, validation fails, and the plan silently keeps the vanilla BatchScanExec. Comet's isInstanceOf[ParquetScan] behaves the same way. This path did work before: Gluten's only structural gate is scan.isInstanceOf[FileScan] and it reads the FileIndex generically, so a converted KSHC table was genuinely offloadable and no longer is.

Gluten's check is a string match, so subclassing would not save it either, and ParquetScan being a case class makes wrapping the right call in my view. So the ask is not to change the implementation but to record the tradeoff: note in the PR description and release notes that KSHC Parquet/ORC tables fall back to JVM reads under Gluten/Comet. If you want an escape hatch it would have to be a new config, since turning off convertMetastoreParquet today routes to HiveScanBuilder rather than to the plain scan.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documented the tradeoff both in the source (class-level scaladoc of KyuubiParquetScan / KyuubiOrcScan, point 3) and added a paragraph to the PR description: KSHC-converted Parquet/ORC tables fall back to JVM reads under Gluten/Comet. A dedicated escape-hatch config can follow up if a real user hits this.

sparkSession,
fileIndex,
schema,
dataSchema,
options,
catalogTable,
hiveTableCatalog)
case _ => HiveScanBuilder(sparkSession, fileIndex, dataSchema, catalogTable)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,18 @@ class HiveTableCatalog(sparkSession: SparkSession)
SupportsNamespaces.PROP_LOCATION,
SupportsNamespaces.PROP_OWNER)

private lazy val hadoopConf: Configuration = {
/**
* Cached Hadoop [[Configuration]] snapshot taken at first catalog use.
*/
private lazy val hadoopConf: Configuration = buildHadoopConf()

/**
* Non-cached Hadoop [[Configuration]] for scan builders. Re-evaluated on
* every call so mid-session confs reach readers.
*/
def newScanHadoopConf(): Configuration = buildHadoopConf()

private def buildHadoopConf(): Configuration = {
val conf = sparkSession.sessionState.newHadoopConf()
catalogOptions.asScala.foreach { case (k, v) => conf.set(k, v) }
if (catalogOptions.containsKey("hive.metastore.uris")) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, CatalogTable
import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Expression}
import org.apache.spark.sql.catalyst.expressions.codegen.GenerateUnsafeProjection
import org.apache.spark.sql.connector.expressions.NamedReference
import org.apache.spark.sql.connector.read.{PartitionReaderFactory, SupportsRuntimeFiltering}
import org.apache.spark.sql.connector.read.{PartitionReaderFactory, Scan, SupportsRuntimeFiltering}
import org.apache.spark.sql.execution.datasources.{FilePartition, PartitionedFile}
import org.apache.spark.sql.execution.datasources.v2.FileScan
import org.apache.spark.sql.hive.kyuubi.connector.HiveBridgeHelper.HiveClientImpl
Expand Down Expand Up @@ -181,6 +181,27 @@ case class HiveScan(
// SupportsRuntimeFiltering implementation
// -------------------------------------------------------------------------------

/**
* The default [[Scan.ColumnarSupportMode.PARTITION_DEFINED]] (SPARK-44505)
* would drive `DataSourceV2ScanExecBase.supportsColumnar` to materialise
* `inputPartitions` during planning (via `HiveScan.partitions` ->
* `HiveCatalogFileIndex.listHiveFiles`), triggering a full-table HDFS
* listing before runtime filters arrive via
* [[SupportsRuntimeFiltering.filter]] and cancelling DPP's end-to-end win.
*
* [[HivePartitionReaderFactory]] only implements the row-based
* `createReader` path (no `supportColumnarReads` / `createColumnarReader`),
* so `HiveScan` is always row-based. Returning `UNSUPPORTED`
* is semantically equivalent to the default behaviour but short-circuits
* `supportsColumnar` without touching `inputPartitions`.
*
* NOTE: If [[HivePartitionReaderFactory]] ever gains columnar support,
* remove this override so `supportsColumnar` reflects reality, otherwise
* columnar-capable partitions would be silently reported as row-based.
*/
override def columnarSupportMode(): Scan.ColumnarSupportMode =
Scan.ColumnarSupportMode.UNSUPPORTED

override def filterAttributes(): Array[NamedReference] = {
HiveRuntimeFilterSupport.filterAttributes(readPartitionSchema.fieldNames.toSeq)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* 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.kyuubi.spark.connector.hive.read

import org.apache.hadoop.conf.Configuration
import org.apache.hadoop.fs.Path
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.catalog.CatalogTable
import org.apache.spark.sql.catalyst.expressions.Expression
import org.apache.spark.sql.connector.expressions.NamedReference
import org.apache.spark.sql.connector.expressions.aggregate.Aggregation
import org.apache.spark.sql.connector.read.{InputPartition, PartitionReaderFactory, Scan, SupportsRuntimeFiltering}
import org.apache.spark.sql.execution.WholeStageCodegenExec
import org.apache.spark.sql.execution.datasources.PartitioningAwareFileIndex
import org.apache.spark.sql.execution.datasources.orc.OrcUtils
import org.apache.spark.sql.execution.datasources.v2.FileScan
import org.apache.spark.sql.execution.datasources.v2.orc.OrcScan
import org.apache.spark.sql.sources.Filter
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.util.CaseInsensitiveStringMap

/**
* A DPP-aware wrapper around Spark's built-in [[OrcScan]] that adds
* [[SupportsRuntimeFiltering]] so Dynamic Partition Pruning can push runtime
* IN predicates down to the Hive partitioned scan.
*
* Implementation notes:
* 1. Only DPP-specific methods ([[filter]] / [[filterAttributes]] /
* [[planInputPartitions]]) contain custom logic, all other methods
* delegate to the wrapped [[OrcScan]].
* 2. [[equals]] uses a `KyuubiOrcScan` type pattern before delegating to
* `inner.equals`, so a plain [[OrcScan]] never compares equal and is
* never reused in its place during exchange/subquery reuse. [[hashCode]]
* is a constant, matching [[FileScan]]'s default.
* 3. Native engines (Gluten / Comet) identify scans by class name, so this
* wrapper is not recognized and falls back to JVM reads.
*/
class KyuubiOrcScan(
val sparkSession: SparkSession,
val hadoopConf: Configuration,
val fileIndex: PartitioningAwareFileIndex,
val dataSchema: StructType,
val readDataSchema: StructType,
val readPartitionSchema: StructType,
val options: CaseInsensitiveStringMap,
val pushedAggregate: Option[Aggregation],
val pushedFilters: Array[Filter],
val partitionFilters: Seq[Expression],
val dataFilters: Seq[Expression],
val catalogTable: CatalogTable)
extends FileScan
with SupportsRuntimeFiltering {

private[hive] val inner: OrcScan = OrcScan(
sparkSession,
hadoopConf,
fileIndex,
dataSchema,
readDataSchema,
readPartitionSchema,
options,
pushedAggregate,
pushedFilters,
partitionFilters,
dataFilters)

private var runtimeFilters: Seq[Expression] = Seq.empty

private val isCaseSensitive = sparkSession.sessionState.conf.caseSensitiveAnalysis

/**
* The default [[Scan.ColumnarSupportMode.PARTITION_DEFINED]] (SPARK-44505)
* would drive `DataSourceV2ScanExecBase.supportsColumnar` to materialise
* `inputPartitions` during planning (via `FileScan.partitions` ->
* `HiveCatalogFileIndex.listFiles`), triggering a full-table HDFS listing
* before runtime filters arrive via [[SupportsRuntimeFiltering.filter]] and
* cancelling DPP's end-to-end win.
*
* We instead decide from sqlConf + schema, matching
* `OrcPartitionReaderFactory.supportColumnarReads` in all non-empty cases.
* When DPP prunes every partition, Spark's default would return
* `UNSUPPORTED` on the empty list, we still return `SUPPORTED`, adding a
* harmless `ColumnarToRow` on an empty RDD.
*/
override def columnarSupportMode(): Scan.ColumnarSupportMode = {
val sqlConf = sparkSession.sessionState.conf
val schema = StructType(readDataSchema.fields ++ readPartitionSchema.fields)
val supportsColumnar = sqlConf.orcVectorizedReaderEnabled &&
sqlConf.wholeStageEnabled &&
!WholeStageCodegenExec.isTooManyFields(sqlConf, schema) &&
schema.forall(s =>
OrcUtils.supportColumnarReads(
s.dataType,
sqlConf.orcVectorizedReaderNestedColumnEnabled))
if (supportsColumnar) Scan.ColumnarSupportMode.SUPPORTED
else Scan.ColumnarSupportMode.UNSUPPORTED
}

override def filterAttributes(): Array[NamedReference] = {
// Under aggregate pushdown, the scan outputs aggregate columns only, so
// partition columns may be absent from its output, runtime filtering on
// them is also meaningless once results are aggregated.
if (pushedAggregate.nonEmpty) Array.empty[NamedReference]
else HiveRuntimeFilterSupport.filterAttributes(readPartitionSchema.fieldNames.toSeq)
}

override def filter(filters: Array[Filter]): Unit = {
runtimeFilters = HiveRuntimeFilterSupport.toCatalystPartitionFilters(
filters,
fileIndex.partitionSchema,
isCaseSensitive)
if (runtimeFilters.nonEmpty) {
logInfo(s"Received ${runtimeFilters.length} runtime partition filter(s) for " +
s"${catalogTable.identifier}")
logDebug(s"Runtime partition filter(s) for ${catalogTable.identifier}: " +
s"${runtimeFilters.mkString(", ")}")
}
}

override def planInputPartitions(): Array[InputPartition] = {
if (runtimeFilters.isEmpty) {
inner.planInputPartitions()
} else {
// Delegate planning to a sibling OrcScan carrying the merged
// partitionFilters ++ runtimeFilters so DPP predicates take effect.
val sibling = OrcScan(
sparkSession,
hadoopConf,
fileIndex,
dataSchema,
readDataSchema,
readPartitionSchema,
options,
pushedAggregate,
pushedFilters,
partitionFilters ++ runtimeFilters,
dataFilters)
sibling.planInputPartitions()
}
}

override def isSplitable(path: Path): Boolean = inner.isSplitable(path)

override def readSchema(): StructType = inner.readSchema()

override def getMetaData(): Map[String, String] = inner.getMetaData()

override def createReaderFactory(): PartitionReaderFactory = inner.createReaderFactory()

override def equals(obj: Any): Boolean = obj match {
case that: KyuubiOrcScan => this.inner.equals(that.inner)
case _ => false
}

override def hashCode(): Int = getClass.hashCode()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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.kyuubi.spark.connector.hive.read

import scala.collection.JavaConverters._

import org.apache.hadoop.conf.Configuration
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.catalyst.catalog.CatalogTable
import org.apache.spark.sql.connector.expressions.aggregate.Aggregation
import org.apache.spark.sql.connector.read.SupportsPushDownAggregates
import org.apache.spark.sql.execution.datasources.{AggregatePushDownUtils, PartitioningAwareFileIndex}
import org.apache.spark.sql.execution.datasources.v2.FileScanBuilder
import org.apache.spark.sql.hive.kyuubi.connector.HiveBridgeHelper
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.sources.Filter
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.util.CaseInsensitiveStringMap

import org.apache.kyuubi.spark.connector.hive.HiveTableCatalog

/**
* A ScanBuilder that mirrors Spark's built-in [[OrcScanBuilder]] but builds
* [[KyuubiOrcScan]] instances, which additionally implement
* `SupportsRuntimeFiltering` so that Dynamic Partition Pruning works when
* a Hive ORC table goes through Spark's vectorized ORC reader path.
*
* Filter, aggregate and column pushdown behaviour matches [[OrcScanBuilder]].
*/
class KyuubiOrcScanBuilder(
sparkSession: SparkSession,
fileIndex: PartitioningAwareFileIndex,
schema: StructType,
dataSchema: StructType,
options: CaseInsensitiveStringMap,
catalogTable: CatalogTable,
hiveTableCatalog: HiveTableCatalog)
extends FileScanBuilder(sparkSession, fileIndex, dataSchema)
with SupportsPushDownAggregates {

/**
* Cloned from a freshly-built per-catalog Hadoop [[Configuration]] so
* per-catalog settings and mid-session confs are both honored, matching
* Spark's built-in `OrcScanBuilder.hadoopConf`. Cloned so per-scan
* `options` do not pollute the source instance.
*/
lazy val hadoopConf: Configuration = {
val conf = new Configuration(hiveTableCatalog.newScanHadoopConf())
// Hadoop Configurations are case sensitive.
options.asCaseSensitiveMap.asScala.foreach { case (k, v) => conf.set(k, v) }
conf
}

private var finalSchema = new StructType()

private var pushedAggregations = Option.empty[Aggregation]

override protected val supportsNestedSchemaPruning: Boolean = true

override def build(): KyuubiOrcScan = {
// the `finalSchema` is either pruned in pushAggregation (if aggregates are
// pushed down), or pruned in readDataSchema() (in regular column pruning). These
// two are mutual exclusive.
if (pushedAggregations.isEmpty) {
finalSchema = readDataSchema()
}
new KyuubiOrcScan(
sparkSession,
hadoopConf,
fileIndex,
dataSchema,
finalSchema,
readPartitionSchema(),
options,
pushedAggregations,
pushedDataFilters,
partitionFilters,
dataFilters,
catalogTable)
}

override def pushDataFilters(dataFilters: Array[Filter]): Array[Filter] = {
if (sparkSession.sessionState.conf.orcFilterPushDown) {
HiveBridgeHelper.orcConvertibleFilters(
readDataSchema(),
SQLConf.get.caseSensitiveAnalysis,
dataFilters.toSeq).toArray
} else {
Array.empty[Filter]
}
}

override def pushAggregation(aggregation: Aggregation): Boolean = {
if (!sparkSession.sessionState.conf.orcAggregatePushDown) {
return false
}

AggregatePushDownUtils.getSchemaForPushedAggregation(
aggregation,
schema,
partitionNameSet,
dataFilters) match {

case Some(schema) =>
finalSchema = schema
this.pushedAggregations = Some(aggregation)
true
case _ => false
}
}
}
Loading
Loading