This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new f262b13d21 feat: support _metadata constant columns in native Parquet
scan (#5237)
f262b13d21 is described below
commit f262b13d2112a78da44d7acc8998b76bc86a6d43
Author: Matt Butrovich <[email protected]>
AuthorDate: Wed Aug 5 08:45:02 2026 -0400
feat: support _metadata constant columns in native Parquet scan (#5237)
* add constant parquet metadata columns (file_path, file_name, file_size,
file_block_start, file_block_length, file_modification_time)
* update tests
* fix compilation
* add shim for FileFormat due to Spark 3.4.
* update diffs to address test failures
* fix: avoid _metadata column name collisions with real columns in native
scan
DataFusion's `table_partition_cols` literal substitution matches columns by
name, not position, so a real data or Hive partition column sharing a name with
one of the six constant metadata columns (e.g. `file_size`) could be silently
overwritten with the metadata value. Spark itself has no such hazard: metadata
attributes get a fresh ExprId regardless of name and are always resolved by
ExprId/ordinal, never by name. Prefix the wire field names with
`_comet_metadata_` so the collision [...]
* query the table directly instead of a view which won't have those
metadata columns
---
.../user-guide/latest/compatibility/scans.md | 4 +-
native/core/src/execution/planner.rs | 30 +++-
.../org/apache/comet/rules/CometScanRule.scala | 21 ++-
.../org/apache/comet/serde/QueryPlanSerde.scala | 1 +
.../apache/comet/serde/contraintExpressions.scala | 39 ++++-
.../comet/serde/operator/CometNativeScan.scala | 25 ++-
.../org/apache/comet/serde/operator/package.scala | 50 ++++--
.../spark/sql/comet/CometNativeScanExec.scala | 7 +-
.../org/apache/comet/shims/ShimFileFormat.scala | 38 ++++-
.../org/apache/comet/shims/ShimFileFormat.scala | 18 +-
.../org/apache/comet/shims/ShimFileFormat.scala | 18 +-
.../org/apache/comet/shims/ShimFileFormat.scala | 18 +-
.../org/apache/comet/shims/ShimFileFormat.scala | 17 +-
.../apache/comet/parquet/ParquetReadSuite.scala | 190 +++++++++++++++++++++
.../apache/comet/rules/CometScanRuleSuite.scala | 20 +++
15 files changed, 449 insertions(+), 47 deletions(-)
diff --git a/docs/source/user-guide/latest/compatibility/scans.md
b/docs/source/user-guide/latest/compatibility/scans.md
index 00b3284e10..071b83ba5a 100644
--- a/docs/source/user-guide/latest/compatibility/scans.md
+++ b/docs/source/user-guide/latest/compatibility/scans.md
@@ -38,8 +38,8 @@ The following features are not supported and cause Comet to
fall back to Spark:
- Default values that are nested types (e.g., maps, arrays, structs). Literal
default values are supported.
- Spark's Datasource V2 API. When `spark.sql.sources.useV1SourceList` does not
include `parquet`, Spark uses the
V2 API for Parquet scans. Comet's Parquet scan only supports the V1 API.
-- Spark metadata columns (e.g., `_metadata.file_path`)
-- No support for row indexes
+- `_metadata.row_index`. Other `_metadata` columns (`file_path`, `file_name`,
`file_size`, `file_block_start`,
+ `file_block_length`, `file_modification_time`) are supported.
- No support for `input_file_name()`, `input_file_block_start()`, or
`input_file_block_length()` SQL functions.
Comet's Parquet scan does not use Spark's `FileScanRDD`, so these functions
cannot populate their values.
- No support for `ignoreMissingFiles` or `ignoreCorruptFiles` being set to
`true`
diff --git a/native/core/src/execution/planner.rs
b/native/core/src/execution/planner.rs
index d4c97c509d..85851b17ca 100644
--- a/native/core/src/execution/planner.rs
+++ b/native/core/src/execution/planner.rs
@@ -1527,12 +1527,30 @@ impl PhysicalPlanner {
));
}
- // Convert the Spark expressions to Physical expressions
- let data_filters: Result<Vec<Arc<dyn PhysicalExpr>>,
ExecutionError> = common
- .data_filters
- .iter()
- .map(|expr| self.create_expr(expr,
Arc::clone(&required_schema)))
- .collect();
+ // data_filters may reference partition columns and constant
metadata columns
+ // (e.g. `_metadata.file_size`), which the Parquet reader
appends after
+ // required_schema's columns once partition_values are
projected into the
+ // batch. Bind against the combined schema so `Bound` indices
resolve
+ // correctly -- Scala's `exprToProto(filter, scan.output)`
+ // (CometNativeScan.scala) numbers columns against that same
ordering.
+ let data_filters: Result<Vec<Arc<dyn PhysicalExpr>>,
ExecutionError> =
+ if common.data_filters.is_empty() {
+ Ok(vec![])
+ } else {
+ let filter_schema: SchemaRef = Arc::new(Schema::new(
+ required_schema
+ .fields()
+ .iter()
+ .chain(partition_schema.fields().iter())
+ .cloned()
+ .collect::<Vec<FieldRef>>(),
+ ));
+ common
+ .data_filters
+ .iter()
+ .map(|expr| self.create_expr(expr,
Arc::clone(&filter_schema)))
+ .collect()
+ };
let default_values: Option<HashMap<Column, ScalarValue>> = if
!common
.default_values
diff --git a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
index a57475d2f5..2c15bb5e4e 100644
--- a/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
+++ b/spark/src/main/scala/org/apache/comet/rules/CometScanRule.scala
@@ -143,11 +143,18 @@ case class CometScanRule(session: SparkSession)
}
private def transformV1Scan(plan: SparkPlan, scanExec: FileSourceScanExec):
SparkPlan = {
- val metadataColNames = metadataCols(scanExec)
- if (metadataColNames.nonEmpty) {
+ // fileConstantMetadataColumns (file_path, file_name, file_size,
file_block_start,
+ // file_block_length, file_modification_time) are known before opening the
file and
+ // supported below via the same projection mechanism as partition columns.
Any other
+ // metadata column (currently only `_metadata.row_index`, generated per
row by the reader)
+ // is not.
+ val constantMetadataColNames =
scanExec.fileConstantMetadataColumns.map(_.name).toSet
+ val unsupportedMetadataColNames =
+ metadataCols(scanExec).filterNot(constantMetadataColNames.contains)
+ if (unsupportedMetadataColNames.nonEmpty) {
return withFallbackReason(
scanExec,
- s"Metadata column(s) ${metadataColNames.mkString(", ")} is not
supported")
+ s"Metadata column(s) ${unsupportedMetadataColNames.mkString(", ")} is
not supported")
}
// On Spark 3.4, injectQueryStageOptimizerRule is unavailable, so
@@ -265,10 +272,6 @@ case class CometScanRule(session: SparkSession)
withFallbackReason(scanExec, "Native Parquet scan does not support
encryption")
return None
}
- if (scanExec.fileConstantMetadataColumns.nonEmpty) {
- withFallbackReason(scanExec, "Native DataFusion scan does not support
metadata columns")
- return None
- }
// input_file_name, input_file_block_start, and input_file_block_length
read from
// InputFileBlockHolder, a thread-local set by Spark's FileScanRDD. The
native DataFusion
// scan does not use FileScanRDD, so these expressions would return
empty/default values.
@@ -279,12 +282,12 @@ case class CometScanRule(session: SparkSession)
}))) {
withFallbackReason(
scanExec,
- "Native DataFusion scan is not compatible with input_file_name, " +
+ "Native Parquet scan is not compatible with input_file_name, " +
"input_file_block_start, or input_file_block_length")
return None
}
if
(ShimFileFormat.findRowIndexColumnIndexInSchema(scanExec.requiredSchema) >= 0) {
- withFallbackReason(scanExec, "Native DataFusion scan does not support
row index generation")
+ withFallbackReason(scanExec, "Native Parquet scan does not support row
index generation")
return None
}
if (!isSchemaSupported(scanExec, r)) {
diff --git a/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
b/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
index 632c4d25fe..4299eb14ba 100644
--- a/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/QueryPlanSerde.scala
@@ -365,6 +365,7 @@ object QueryPlanSerde extends Logging with CometExprShim
with CometTypeShim {
classOf[CheckOverflow] -> CometCheckOverflow,
classOf[Coalesce] -> CometCoalesce,
classOf[KnownFloatingPointNormalized] ->
CometKnownFloatingPointNormalized,
+ classOf[KnownNotNull] -> CometKnownNotNull,
classOf[KnownNullable] -> CometKnownNullable,
classOf[Literal] -> CometLiteral,
classOf[MakeDecimal] -> CometMakeDecimal,
diff --git
a/spark/src/main/scala/org/apache/comet/serde/contraintExpressions.scala
b/spark/src/main/scala/org/apache/comet/serde/contraintExpressions.scala
index 460f23485d..44158be445 100644
--- a/spark/src/main/scala/org/apache/comet/serde/contraintExpressions.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/contraintExpressions.scala
@@ -19,7 +19,7 @@
package org.apache.comet.serde
-import org.apache.spark.sql.catalyst.expressions.{Attribute,
KnownFloatingPointNormalized, KnownNullable}
+import org.apache.spark.sql.catalyst.expressions.{Attribute,
KnownFloatingPointNormalized, KnownNotNull, KnownNullable, TaggingExpression}
import org.apache.spark.sql.catalyst.optimizer.NormalizeNaNAndZero
import org.apache.comet.serde.QueryPlanSerde.{exprToProtoInternal,
serializeDataType}
@@ -78,17 +78,40 @@ object CometKnownFloatingPointNormalized
}
/**
- * `KnownNullable` is a tagging expression that only marks its child as
nullable; it is a runtime
- * no-op (`eval` returns the child's value unchanged). Spark's time-window
resolution wraps window
- * bounds in `KnownNullable`, so supporting it lets those grouping queries run
natively. We simply
- * serialize the child and drop the tag.
+ * `KnownNullable` and `KnownNotNull` (below) are Spark `TaggingExpression`s
that only annotate a
+ * child's nullability; both are runtime no-ops whose `eval` returns the
child's value unchanged.
+ * We serialize the child directly and drop the tag.
*/
-object CometKnownNullable extends CometExpressionSerde[KnownNullable] {
- override def convert(
- expr: KnownNullable,
+private object CometTaggingExpression {
+ def convert(
+ expr: TaggingExpression,
inputs: Seq[Attribute],
binding: Boolean): Option[ExprOuterClass.Expr] = {
val optExpr = exprToProtoInternal(expr.child, inputs, binding)
optExpr
}
}
+
+/**
+ * Spark's time-window resolution wraps window bounds in `KnownNullable`, so
supporting it lets
+ * those grouping queries run natively.
+ */
+object CometKnownNullable extends CometExpressionSerde[KnownNullable] {
+ override def convert(
+ expr: KnownNullable,
+ inputs: Seq[Attribute],
+ binding: Boolean): Option[ExprOuterClass.Expr] =
+ CometTaggingExpression.convert(expr, inputs, binding)
+}
+
+/**
+ * Spark's `FileSourceStrategy` wraps the `_metadata` struct in `KnownNotNull`
so the schema
+ * advertises it as non-nullable without relying on `CreateStruct`'s own
nullability inference.
+ */
+object CometKnownNotNull extends CometExpressionSerde[KnownNotNull] {
+ override def convert(
+ expr: KnownNotNull,
+ inputs: Seq[Attribute],
+ binding: Boolean): Option[ExprOuterClass.Expr] =
+ CometTaggingExpression.convert(expr, inputs, binding)
+}
diff --git
a/spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala
b/spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala
index f017acfcd2..0240bd4a07 100644
--- a/spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/operator/CometNativeScan.scala
@@ -29,6 +29,7 @@ import org.apache.spark.sql.comet.{CometNativeExec,
CometNativeScanExec, CometSc
import org.apache.spark.sql.execution.{FileSourceScanExec, InSubqueryExec,
SubqueryAdaptiveBroadcastExec}
import org.apache.spark.sql.execution.datasources.parquet.ParquetUtils
import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.types.StructField
import org.apache.comet.{CometConf, ConfigEntry}
import org.apache.comet.CometConf.COMET_EXEC_ENABLED
@@ -45,6 +46,10 @@ import org.apache.comet.serde.QueryPlanSerde.{exprToProto,
serializeDataType}
*/
object CometNativeScan extends CometOperatorSerde[CometScanExec] with Logging {
+ // DataFusion's table_partition_cols literal substitution matches by name,
so a bare name
+ // like "file_size" could collide with a real column of the same name.
Prefix to avoid it.
+ private val constantMetadataFieldPrefix = "_comet_metadata_"
+
/** Determine whether the scan is supported and tag the Spark plan with any
fallback reasons */
def isSupported(scanExec: FileSourceScanExec): Boolean = {
@@ -165,7 +170,18 @@ object CometNativeScan extends
CometOperatorSerde[CometScanExec] with Logging {
.headOption
.map(_.getPath.toUri)
- val partitionSchema = schema2Proto(scan.relation.partitionSchema)
+ // Constant metadata columns (file_path, file_name, file_size,
file_block_start,
+ // file_block_length, file_modification_time) are known before opening
the file and
+ // constant for every row read from it, exactly like partition columns.
Spark places
+ // them immediately after partition columns in `scan.output`
+ // (FileSourceStrategy.scala: readDataColumns ++
generatedMetadataColumns ++
+ // partitionColumns ++ constantMetadataColumns), so appending them after
the real
+ // partition schema here keeps the two in lockstep.
+ val constantMetadataFields =
scan.wrapped.fileConstantMetadataColumns.map(attr =>
+ StructField(s"$constantMetadataFieldPrefix${attr.name}",
attr.dataType, attr.nullable))
+ val partitionSchemaFields = scan.relation.partitionSchema.fields.toSeq ++
+ constantMetadataFields
+ val partitionSchema = schema2Proto(partitionSchemaFields)
val requiredSchema = schema2Proto(scan.requiredSchema)
val dataSchema = schema2Proto(scan.relation.dataSchema)
@@ -173,15 +189,16 @@ object CometNativeScan extends
CometOperatorSerde[CometScanExec] with Logging {
scan.relation.dataSchema.fieldIndex(field.name)
})
val partitionSchemaIndexes = scan.relation.dataSchema.fields.length until
- (scan.relation.dataSchema.length +
scan.relation.partitionSchema.fields.length)
+ (scan.relation.dataSchema.length + partitionSchemaFields.length)
val projectionVector = (dataSchemaIndexes ++
partitionSchemaIndexes).map(idx =>
idx.toLong.asInstanceOf[java.lang.Long])
commonBuilder.addAllProjectionVector(projectionVector.asJava)
- // In `CometScanRule`, we ensure partitionSchema is supported.
- assert(partitionSchema.length ==
scan.relation.partitionSchema.fields.length)
+ // In `CometScanRule`, we ensure partitionSchema (including constant
metadata columns)
+ // is supported.
+ assert(partitionSchema.length == partitionSchemaFields.length)
commonBuilder.addAllDataSchema(dataSchema.asJava)
commonBuilder.addAllRequiredSchema(requiredSchema.asJava)
diff --git a/spark/src/main/scala/org/apache/comet/serde/operator/package.scala
b/spark/src/main/scala/org/apache/comet/serde/operator/package.scala
index 8bf6832f26..cb7702083b 100644
--- a/spark/src/main/scala/org/apache/comet/serde/operator/package.scala
+++ b/spark/src/main/scala/org/apache/comet/serde/operator/package.scala
@@ -19,13 +19,14 @@
package org.apache.comet.serde
-import org.apache.spark.sql.catalyst.expressions.Literal
-import org.apache.spark.sql.execution.datasources.FilePartition
+import org.apache.spark.sql.catalyst.expressions.{AttributeReference, Literal}
+import org.apache.spark.sql.execution.datasources.{FilePartition,
PartitionedFile}
import org.apache.spark.sql.execution.datasources.parquet.ParquetUtils
import org.apache.spark.sql.types.{StructField, StructType}
import org.apache.comet.parquet.CometParquetUtils
import org.apache.comet.serde.QueryPlanSerde.{exprToProto, serializeDataType}
+import org.apache.comet.shims.ShimFileFormat
package object operator {
@@ -47,7 +48,10 @@ package object operator {
def partition2Proto(
partition: FilePartition,
- partitionSchema: StructType): OperatorOuterClass.SparkFilePartition = {
+ partitionSchema: StructType,
+ constantMetadataColumns: Seq[AttributeReference] = Seq.empty,
+ fileConstantMetadataExtractors: Map[String, PartitionedFile => Any] =
Map.empty)
+ : OperatorOuterClass.SparkFilePartition = {
val partitionBuilder = OperatorOuterClass.SparkFilePartition.newBuilder()
partition.files.foreach(file => {
// Process the partition values
@@ -56,16 +60,32 @@ package object operator {
val partitionVals =
partitionValues.toSeq(partitionSchema).zipWithIndex.map { case (value,
i) =>
val attr = partitionSchema(i)
- val valueProto = exprToProto(Literal(value, attr.dataType),
Seq.empty)
- // In `CometScanRule`, we have already checked that all partition
values are
- // supported. So, we can safely use `get` here.
- assert(
- valueProto.isDefined,
- s"Unsupported partition value: $value, type: ${attr.dataType}")
- valueProto.get
+ literalToProto(
+ Literal(value, attr.dataType),
+ s"partition value: $value, type: ${attr.dataType}")
}
+ // Constant metadata columns (file_path, file_name, file_size,
file_block_start,
+ // file_block_length, file_modification_time) are, like partition
columns, known before
+ // opening the file and constant for every row read from it. Reuse the
same
+ // partition-value wire format and projection mechanism, and Spark's own
extractor
+ // dispatch (which also covers custom per-format overrides), rather than
a bespoke one.
+ // getFileConstantMetadataColumnValue's Literal has an inferred, not
declared, dataType
+ // (e.g. file_modification_time's raw micros value infers as LongType,
not
+ // TimestampType) -- take only its value and retype against the
attribute's actual
+ // dataType, exactly as Spark's own FileFormat.updateMetadataInternalRow
does
+ // (`row.update(i, literal.value)`).
+ val metadataVals = constantMetadataColumns.map { attr =>
+ val value = ShimFileFormat
+ .getFileConstantMetadataColumnValue(
+ attr.name,
+ file,
+ fileConstantMetadataExtractors,
+ attr.dataType)
+ .value
+ literalToProto(Literal(value, attr.dataType), s"metadata column value
for ${attr.name}")
+ }
val fileBuilder = OperatorOuterClass.SparkPartitionedFile.newBuilder()
- partitionVals.foreach(fileBuilder.addPartitionValues)
+ (partitionVals ++ metadataVals).foreach(fileBuilder.addPartitionValues)
fileBuilder
.setFilePath(file.filePath.toString)
.setStart(file.start)
@@ -75,4 +95,12 @@ package object operator {
})
partitionBuilder.build()
}
+
+ // In `CometScanRule`, we have already checked that all partition and
metadata column values
+ // are supported. So, we can safely use `get` here.
+ private def literalToProto(literal: Literal, description: String):
ExprOuterClass.Expr = {
+ val valueProto = exprToProto(literal, Seq.empty)
+ assert(valueProto.isDefined, s"Unsupported $description")
+ valueProto.get
+ }
}
diff --git
a/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
b/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
index 52990cfa3d..fbebc42004 100644
--- a/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
+++ b/spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala
@@ -40,6 +40,7 @@ import com.google.common.base.Objects
import org.apache.comet.parquet.CometParquetUtils
import org.apache.comet.serde.OperatorOuterClass.Operator
import org.apache.comet.serde.QueryPlanSerde.exprToProto
+import org.apache.comet.shims.ShimFileFormat
/**
* Native scan operator for DataSource V1 Parquet files using DataFusion's
ParquetExec.
@@ -218,7 +219,11 @@ case class CometNativeScanExec(
// Serialize each partition's files
import org.apache.comet.serde.operator.partition2Proto
val perPartitionBytes = filePartitions.map { filePartition =>
- val partitionProto = partition2Proto(filePartition,
relation.partitionSchema)
+ val partitionProto = partition2Proto(
+ filePartition,
+ relation.partitionSchema,
+ originalPlan.fileConstantMetadataColumns,
+ ShimFileFormat.fileConstantMetadataExtractors(relation.fileFormat))
val partitionNativeScan =
org.apache.comet.serde.OperatorOuterClass.NativeScan
.newBuilder()
.setFilePartition(partitionProto)
diff --git
a/spark/src/main/spark-3.4/org/apache/comet/shims/ShimFileFormat.scala
b/spark/src/main/spark-3.4/org/apache/comet/shims/ShimFileFormat.scala
index 7b4911e81f..6906a39fbf 100644
--- a/spark/src/main/spark-3.4/org/apache/comet/shims/ShimFileFormat.scala
+++ b/spark/src/main/spark-3.4/org/apache/comet/shims/ShimFileFormat.scala
@@ -19,8 +19,10 @@
package org.apache.comet.shims
-import org.apache.spark.sql.execution.datasources.{FileFormat, RowIndexUtil}
-import org.apache.spark.sql.types.StructType
+import org.apache.hadoop.fs.Path
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.execution.datasources.{FileFormat,
PartitionedFile, RowIndexUtil}
+import org.apache.spark.sql.types.{DataType, StructType}
object ShimFileFormat {
@@ -30,4 +32,36 @@ object ShimFileFormat {
def findRowIndexColumnIndexInSchema(sparkSchema: StructType): Int =
RowIndexUtil.findRowIndexColumnIndexInSchema(sparkSchema)
+
+ // Spark 3.4 has no per-format metadata extractor concept (added in Spark
3.5, SPARK-43868);
+ // it derives these values inline in FileFormat.updateMetadataInternalRow.
Replicate that
+ // logic here so callers can use the same extractor-map shape across all
Spark versions.
+ private val baseMetadataExtractors: Map[String, PartitionedFile => Any] =
Map(
+ FileFormat.FILE_PATH -> { pf: PartitionedFile =>
+ // Use `new Path(Path.toString)` as a form of canonicalization
+ new Path(pf.filePath.toPath.toString).toUri.toString
+ },
+ FileFormat.FILE_NAME -> { pf: PartitionedFile =>
+ pf.filePath.toUri.getRawPath.split("/").lastOption.getOrElse("")
+ },
+ FileFormat.FILE_SIZE -> { pf: PartitionedFile => pf.fileSize },
+ FileFormat.FILE_BLOCK_START -> { pf: PartitionedFile => pf.start },
+ FileFormat.FILE_BLOCK_LENGTH -> { pf: PartitionedFile => pf.length },
+ // The modificationTime from the file has millisecond granularity, but the
TimestampType for
+ // `file_modification_time` has microsecond granularity.
+ FileFormat.FILE_MODIFICATION_TIME -> { pf: PartitionedFile =>
pf.modificationTime * 1000 })
+
+ def fileConstantMetadataExtractors(
+ fileFormat: FileFormat): Map[String, PartitionedFile => Any] =
+ baseMetadataExtractors
+
+ // dataType is unused on this Spark version;
getFileConstantMetadataColumnValue only gained
+ // a dataType parameter in Spark 4.2 (SPARK-56931). Accepted here so callers
can pass it
+ // uniformly across Spark versions.
+ def getFileConstantMetadataColumnValue(
+ name: String,
+ file: PartitionedFile,
+ extractors: Map[String, PartitionedFile => Any],
+ dataType: DataType): Literal =
+ Literal(extractors(name)(file))
}
diff --git
a/spark/src/main/spark-3.5/org/apache/comet/shims/ShimFileFormat.scala
b/spark/src/main/spark-3.5/org/apache/comet/shims/ShimFileFormat.scala
index 1702db135a..9506a851c0 100644
--- a/spark/src/main/spark-3.5/org/apache/comet/shims/ShimFileFormat.scala
+++ b/spark/src/main/spark-3.5/org/apache/comet/shims/ShimFileFormat.scala
@@ -19,9 +19,11 @@
package org.apache.comet.shims
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile}
import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
import org.apache.spark.sql.execution.datasources.parquet.ParquetRowIndexUtil
-import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.types.{DataType, StructType}
object ShimFileFormat {
// A name for a temporary column that holds row indexes computed by the file
format reader
@@ -30,4 +32,18 @@ object ShimFileFormat {
def findRowIndexColumnIndexInSchema(sparkSchema: StructType): Int =
ParquetRowIndexUtil.findRowIndexColumnIndexInSchema(sparkSchema)
+
+ def fileConstantMetadataExtractors(
+ fileFormat: FileFormat): Map[String, PartitionedFile => Any] =
+ fileFormat.fileConstantMetadataExtractors
+
+ // dataType is unused on this Spark version;
getFileConstantMetadataColumnValue only gained
+ // a dataType parameter in Spark 4.2 (SPARK-56931). Accepted here so callers
can pass it
+ // uniformly across Spark versions.
+ def getFileConstantMetadataColumnValue(
+ name: String,
+ file: PartitionedFile,
+ extractors: Map[String, PartitionedFile => Any],
+ dataType: DataType): Literal =
+ FileFormat.getFileConstantMetadataColumnValue(name, file, extractors)
}
diff --git
a/spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
b/spark/src/main/spark-4.0/org/apache/comet/shims/ShimFileFormat.scala
similarity index 62%
copy from spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
copy to spark/src/main/spark-4.0/org/apache/comet/shims/ShimFileFormat.scala
index 1702db135a..9506a851c0 100644
--- a/spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
+++ b/spark/src/main/spark-4.0/org/apache/comet/shims/ShimFileFormat.scala
@@ -19,9 +19,11 @@
package org.apache.comet.shims
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile}
import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
import org.apache.spark.sql.execution.datasources.parquet.ParquetRowIndexUtil
-import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.types.{DataType, StructType}
object ShimFileFormat {
// A name for a temporary column that holds row indexes computed by the file
format reader
@@ -30,4 +32,18 @@ object ShimFileFormat {
def findRowIndexColumnIndexInSchema(sparkSchema: StructType): Int =
ParquetRowIndexUtil.findRowIndexColumnIndexInSchema(sparkSchema)
+
+ def fileConstantMetadataExtractors(
+ fileFormat: FileFormat): Map[String, PartitionedFile => Any] =
+ fileFormat.fileConstantMetadataExtractors
+
+ // dataType is unused on this Spark version;
getFileConstantMetadataColumnValue only gained
+ // a dataType parameter in Spark 4.2 (SPARK-56931). Accepted here so callers
can pass it
+ // uniformly across Spark versions.
+ def getFileConstantMetadataColumnValue(
+ name: String,
+ file: PartitionedFile,
+ extractors: Map[String, PartitionedFile => Any],
+ dataType: DataType): Literal =
+ FileFormat.getFileConstantMetadataColumnValue(name, file, extractors)
}
diff --git
a/spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
b/spark/src/main/spark-4.1/org/apache/comet/shims/ShimFileFormat.scala
similarity index 62%
copy from spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
copy to spark/src/main/spark-4.1/org/apache/comet/shims/ShimFileFormat.scala
index 1702db135a..9506a851c0 100644
--- a/spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
+++ b/spark/src/main/spark-4.1/org/apache/comet/shims/ShimFileFormat.scala
@@ -19,9 +19,11 @@
package org.apache.comet.shims
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile}
import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
import org.apache.spark.sql.execution.datasources.parquet.ParquetRowIndexUtil
-import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.types.{DataType, StructType}
object ShimFileFormat {
// A name for a temporary column that holds row indexes computed by the file
format reader
@@ -30,4 +32,18 @@ object ShimFileFormat {
def findRowIndexColumnIndexInSchema(sparkSchema: StructType): Int =
ParquetRowIndexUtil.findRowIndexColumnIndexInSchema(sparkSchema)
+
+ def fileConstantMetadataExtractors(
+ fileFormat: FileFormat): Map[String, PartitionedFile => Any] =
+ fileFormat.fileConstantMetadataExtractors
+
+ // dataType is unused on this Spark version;
getFileConstantMetadataColumnValue only gained
+ // a dataType parameter in Spark 4.2 (SPARK-56931). Accepted here so callers
can pass it
+ // uniformly across Spark versions.
+ def getFileConstantMetadataColumnValue(
+ name: String,
+ file: PartitionedFile,
+ extractors: Map[String, PartitionedFile => Any],
+ dataType: DataType): Literal =
+ FileFormat.getFileConstantMetadataColumnValue(name, file, extractors)
}
diff --git
a/spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
b/spark/src/main/spark-4.2/org/apache/comet/shims/ShimFileFormat.scala
similarity index 63%
rename from spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
rename to spark/src/main/spark-4.2/org/apache/comet/shims/ShimFileFormat.scala
index 1702db135a..4aee5671de 100644
--- a/spark/src/main/spark-4.x/org/apache/comet/shims/ShimFileFormat.scala
+++ b/spark/src/main/spark-4.2/org/apache/comet/shims/ShimFileFormat.scala
@@ -19,9 +19,11 @@
package org.apache.comet.shims
+import org.apache.spark.sql.catalyst.expressions.Literal
+import org.apache.spark.sql.execution.datasources.{FileFormat, PartitionedFile}
import org.apache.spark.sql.execution.datasources.parquet.ParquetFileFormat
import org.apache.spark.sql.execution.datasources.parquet.ParquetRowIndexUtil
-import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.types.{DataType, StructType}
object ShimFileFormat {
// A name for a temporary column that holds row indexes computed by the file
format reader
@@ -30,4 +32,17 @@ object ShimFileFormat {
def findRowIndexColumnIndexInSchema(sparkSchema: StructType): Int =
ParquetRowIndexUtil.findRowIndexColumnIndexInSchema(sparkSchema)
+
+ def fileConstantMetadataExtractors(
+ fileFormat: FileFormat): Map[String, PartitionedFile => Any] =
+ fileFormat.fileConstantMetadataExtractors
+
+ // Spark 4.2 (SPARK-56931) added a required dataType parameter so complex
constant metadata
+ // values go through Literal.create instead of Literal.apply's type
inference.
+ def getFileConstantMetadataColumnValue(
+ name: String,
+ file: PartitionedFile,
+ extractors: Map[String, PartitionedFile => Any],
+ dataType: DataType): Literal =
+ FileFormat.getFileConstantMetadataColumnValue(name, file, extractors,
dataType)
}
diff --git
a/spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala
b/spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala
index a5fc92a5eb..684c4d6581 100644
--- a/spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/parquet/ParquetReadSuite.scala
@@ -521,6 +521,196 @@ abstract class ParquetReadSuite extends CometTestBase {
}
}
+ test("read _metadata constant columns via native scan") {
+ // TODO(https://github.com/apache/datafusion-comet/issues/3432):
`_metadata.row_index` is
+ // generated per row by the reader, not constant per file, so it needs
DataFusion's
+ // virtual-column mechanism rather than the partition-value path used
here. Not covered.
+ withTempPath { dir =>
+ (1 to 100).toDF("id").repartition(1).write.parquet(dir.getCanonicalPath)
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .select(
+ $"id",
+ $"_metadata.file_path",
+ $"_metadata.file_name",
+ $"_metadata.file_size",
+ $"_metadata.file_block_start",
+ $"_metadata.file_block_length",
+ $"_metadata.file_modification_time")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+
+ /**
+ * Two tiny files easily fit in one Spark partition by data volume, but
+ * spark.sql.files.minPartitionNum defaults to the session's target
parallelism, which pushes
+ * maxSplitBytes down to spark.sql.files.openCostInBytes; a single file's
own virtual open-cost
+ * surcharge then already consumes that budget, so Spark schedules one file
per task instead of
+ * packing both together. Forcing minPartitionNum to 1 is what actually gets
both files into one
+ * partition, so partition2Proto's multi-file loop is exercised instead of
one file per task.
+ */
+ private val forceSinglePartitionConf: (String, String) =
+ SQLConf.FILES_MIN_PARTITION_NUM.key -> "1"
+
+ /**
+ * Writes two Parquet files with distinct row counts (and thus distinct file
sizes) into `dir`,
+ * and returns their (file_path, file_size) pairs sorted by size. Discovers
them with Comet
+ * disabled so the tests below filter on ground truth rather than hardcoded
assumptions.
+ */
+ private def writeTwoFilesAndDiscoverMetadata(dir: File): Array[(String,
Long)] = {
+ (1 to
5).toDF("id").repartition(1).write.mode("overwrite").parquet(dir.getCanonicalPath)
+ (1000 to
1999).toDF("id").repartition(1).write.mode("append").parquet(dir.getCanonicalPath)
+ var files: Array[(String, Long)] = null
+ withSQLConf(forceSinglePartitionConf) {
+ assert(
+ spark.read.parquet(dir.getCanonicalPath).rdd.getNumPartitions == 1,
+ "expected both files to be packed into the same Spark partition, to
exercise " +
+ "partition2Proto's multi-file loop rather than one file per task")
+ withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+ files = spark.read
+ .parquet(dir.getCanonicalPath)
+ .select($"_metadata.file_path", $"_metadata.file_size")
+ .distinct()
+ .as[(String, Long)]
+ .collect()
+ .sortBy(_._2)
+ }
+ }
+ files
+ }
+
+ test("filter on _metadata.file_size selects rows from the matching file
only") {
+ withTempPath { dir =>
+ val files = writeTwoFilesAndDiscoverMetadata(dir)
+ assert(files.length == 2, s"expected two distinct files, got
${files.toSeq}")
+ val (_, smallerSize) = files(0)
+
+ withSQLConf(forceSinglePartitionConf) {
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .filter($"_metadata.file_size" === smallerSize)
+ .select($"id")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+ }
+
+ test("filter on _metadata.file_size using a range predicate") {
+ withTempPath { dir =>
+ val files = writeTwoFilesAndDiscoverMetadata(dir)
+ val (_, smallerSize) = files(0)
+
+ withSQLConf(forceSinglePartitionConf) {
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .filter($"_metadata.file_size" > smallerSize)
+ .select($"id")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+ }
+
+ test("filter combining _metadata column and a data column") {
+ withTempPath { dir =>
+ val files = writeTwoFilesAndDiscoverMetadata(dir)
+ val (_, largerSize) = files(1)
+
+ withSQLConf(forceSinglePartitionConf) {
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .filter($"_metadata.file_size" === largerSize && $"id" > 1500)
+ .select($"id")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+ }
+
+ test("filter on _metadata.file_path exact match") {
+ withTempPath { dir =>
+ val files = writeTwoFilesAndDiscoverMetadata(dir)
+ val (targetPath, _) = files(0)
+
+ withSQLConf(forceSinglePartitionConf) {
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .filter($"_metadata.file_path" === targetPath)
+ .select($"id")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+ }
+
+ test("read _metadata constant columns together with a real Hive partition
column") {
+ withTempPath { dir =>
+ Seq((1, "a"), (2, "a"), (3, "b"), (4, "b"))
+ .toDF("id", "pcol")
+ .repartition(1)
+ .write
+ .partitionBy("pcol")
+ .parquet(dir.getCanonicalPath)
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .select($"id", $"pcol", $"_metadata.file_path", $"_metadata.file_size")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+
+ test("filter combining a real Hive partition column and a metadata column") {
+ withTempPath { dir =>
+ Seq((1, "a"), (2, "a"), (3, "b"), (4, "b"))
+ .toDF("id", "pcol")
+ .repartition(1)
+ .write
+ .partitionBy("pcol")
+ .parquet(dir.getCanonicalPath)
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .filter($"pcol" === "b" && $"_metadata.file_size" > 0)
+ .select($"id")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+
+ test("_metadata.file_path and file_name are url-encoded for a directory with
a space") {
+ withTempDir { parent =>
+ val dir = new File(parent, "dir with space")
+ (1 to 10).toDF("id").repartition(1).write.parquet(dir.getCanonicalPath)
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .select($"id", $"_metadata.file_path", $"_metadata.file_name")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+
+ test("_metadata column does not collide with a data column of the same
name") {
+ withTempPath { dir =>
+ Seq((1L, 10), (2L, 20))
+ .toDF("file_size", "x")
+ .repartition(1)
+ .write
+ .parquet(dir.getCanonicalPath)
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .select($"file_size", $"x", $"_metadata.file_size".as("meta_size"))
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+
+ test("_metadata column does not collide with a partition column of the same
name") {
+ withTempPath { dir =>
+ Seq((1, 100L), (2, 200L))
+ .toDF("id", "file_size")
+ .repartition(1)
+ .write
+ .partitionBy("file_size")
+ .parquet(dir.getCanonicalPath)
+ val df = spark.read
+ .parquet(dir.getCanonicalPath)
+ .select($"id", $"file_size", $"_metadata.file_size")
+ checkSparkAnswerAndOperator(df)
+ }
+ }
+
test("fix: string partition column with incorrect offset buffer") {
def makeRawParquetFile(
path: Path,
diff --git
a/spark/src/test/scala/org/apache/comet/rules/CometScanRuleSuite.scala
b/spark/src/test/scala/org/apache/comet/rules/CometScanRuleSuite.scala
index f444fe62c9..fb39e340d1 100644
--- a/spark/src/test/scala/org/apache/comet/rules/CometScanRuleSuite.scala
+++ b/spark/src/test/scala/org/apache/comet/rules/CometScanRuleSuite.scala
@@ -134,4 +134,24 @@ class CometScanRuleSuite extends CometTestBase {
}
}
+ test("CometScanRule should fallback to Spark for unsupported _metadata
columns") {
+ withTempPath { path =>
+ createTestDataFrame.write.parquet(path.toString)
+
+ // A temp view's output schema is fixed at creation time (here [id,
name]), so
+ // `_metadata` cannot resolve through one; query the relation directly.
+ for (df <- Seq(
+ spark.read.parquet(path.toString).select("id", "_metadata"),
+ spark.read.parquet(path.toString).selectExpr("id",
"_metadata.row_index"))) {
+ var sparkPlan: SparkPlan = null
+ withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+ sparkPlan = df.queryExecution.executedPlan
+ }
+ val transformedPlan = applyCometScanRule(stripAQEPlan(sparkPlan))
+ assert(countOperators(transformedPlan, classOf[FileSourceScanExec]) ==
1)
+ assert(countOperators(transformedPlan, classOf[CometScanExec]) == 0)
+ }
+ }
+ }
+
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]