sunchao commented on code in PR #7196:
URL: https://github.com/apache/datafusion-comet/pull/7196#discussion_r4367844097


##########
native/core/src/parquet/field_resolver.rs:
##########
@@ -47,6 +47,43 @@ pub(crate) enum Selection<'a> {
     ByName(&'a [usize]),
 }
 
+/// The strongest claim on one file field. Root remapping and nested 
conversion give a file
+/// field to this claimant only. Repeated id claims keep the last requested 
field, matching
+/// Spark parquet-mr's `ParquetRowConverter` id-to-index `toMap`. Equal name 
claims keep the
+/// earliest requested field, retaining the root remap's name-matching 
behavior.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum FieldClaim {
+    /// The requested field selects this file field by Parquet field id.
+    IdMatch(usize),
+    /// An id-bearing requested field has this file field's folded name. It 
must not reach this
+    /// field by name when its id is missing or selects a different field. 
Only an id match can
+    /// override this claim. The root hides it with a placeholder; nested 
fields read NULL.
+    Shadowed,
+    /// The requested field selects this file field by folded name.
+    NameMatch(usize),
+    /// No requested field claims this file field. The root keeps its original 
name.
+    Unchanged,
+}
+
+impl FieldClaim {
+    fn strength(self) -> u8 {
+        match self {
+            Self::IdMatch(_) => 3,
+            Self::Shadowed => 2,
+            Self::NameMatch(_) => 1,
+            Self::Unchanged => 0,
+        }
+    }
+
+    fn claim(&mut self, claim: Self) {
+        if claim.strength() > self.strength()
+            || matches!((*self, claim), (Self::IdMatch(_), Self::IdMatch(_)))

Review Comment:
   [P2] An unprojected alias can take the selected column’s value. With 
field-ID reads enabled, write `A = 7` with ID 1, supply a read schema with `x` 
and `y` both carrying ID 1, and select only `x`. The JVM guard sees only `x` in 
`requiredSchema`. Root remapping sees both fields and this tie-break assigns 
`A` to unread `y`, so `SELECT x` silently returns `NULL`. Extend the 
duplicate-ID fallback to cover a required root whose ID also occurs on another 
root in the serialized schema.
   
   Evidence: Spark 4.1.3 and 4.2.0 each showed `requiredSchema=[x]` and 
`relation.dataSchema=[x,y]`. Parquet-mr and the vectorized reader both returned 
`7` for either alias projected separately. On the same files, the base native 
adapter returned `7` for `x`. The head rewrote `x` to a literal `NULL` and its 
`DataSourceExec` scan returned `NULL`.



##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -461,9 +462,7 @@ pub(crate) fn match_struct_fields(
         parquet_options.case_sensitive,
         parquet_options.use_field_id,
     );
-    (0..to_fields.len())
-        .map(|to_pos| resolver.resolve(to_pos))
-        .collect()
+    resolver.resolve_all()

Review Comment:
   [P2] Using the arbitrated indices for file-open validation hides an 
incompatible nested shape. With case-insensitive field-ID reads, write `s.A` as 
`BIGINT` with ID 1, then request `s.x BIGINT` with ID 1 and `s.a 
STRUCT<v:BIGINT>` without an ID. Spark clips the physical primitive against the 
requested struct and rejects the read. `resolve_all()` clears the losing index 
before `check_conversion` sees it, so Comet returns `(7, NULL)`. Validate 
independently selected container shapes before masking losers for value 
conversion.
   
   Evidence: On the same one-row fixture, both readers on Spark 4.1.3 and 4.2.0 
raised `ClassCastException: optional int64 A = 1 is not a group`. The base 
native adapter rejected `INT64` at `[s, A]`. At head, `check_file_conversions` 
accepted the schema and the native scan produced `x=7, a=NULL`.



##########
spark/src/test/scala/org/apache/comet/exec/CometNativeReaderSuite.scala:
##########
@@ -1269,6 +1270,373 @@ class CometNativeReaderSuite extends CometTestBase with 
AdaptiveSparkPlanHelper
       nullable = true,
       new MetadataBuilder().putLong("parquet.field.id", id.toLong).build())
 
+  private def withFieldClaimFile(nested: Boolean)(f: (String, StructType) => 
Unit): Unit = {
+    val id10 = new MetadataBuilder().putLong("parquet.field.id", 10L).build()
+    def wrapSchema(fields: StructType): StructType =
+      if (nested) StructType(Seq(StructField("s", fields, nullable = true, 
id10)))
+      else fields
+    val writeSchema = wrapSchema(StructType(Seq(withFieldId("A", 1))))
+    val readSchema = wrapSchema(
+      StructType(Seq(withFieldId("x", 1), StructField("a", LongType, nullable 
= true))))
+    withSQLConf(
+      SQLConf.CASE_SENSITIVE.key -> "false",
+      SQLConf.PARQUET_FIELD_ID_WRITE_ENABLED.key -> "true",
+      SQLConf.PARQUET_FIELD_ID_READ_ENABLED.key -> "true") {
+      withTempPath { path =>
+        withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+          val rows = (1L to 3L).map(value => if (nested) Row(Row(value)) else 
Row(value))
+          spark
+            .createDataFrame(spark.sparkContext.parallelize(rows, 1), 
writeSchema)
+            .write
+            .parquet(path.getCanonicalPath)
+        }
+        f(path.getCanonicalPath, readSchema)
+      }
+    }
+  }
+
+  private def fieldClaimReference(query: => DataFrame): Seq[Row] = {
+    var rows = Seq.empty[Row]
+    withSQLConf(
+      CometConf.COMET_ENABLED.key -> "false",
+      SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false",
+      SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED.key -> "false") {
+      val df = query
+      val scans = collect(df.queryExecution.executedPlan) { case scan: 
FileSourceScanExec =>
+        scan
+      }
+      assert(scans.nonEmpty && scans.forall(!_.supportsColumnar))
+      rows = df.collect().toSeq
+    }
+    rows
+  }
+
+  private def checkNativeFieldClaimAnswer(df: DataFrame, expected: Seq[Row]): 
Unit = {
+    assert(
+      collect(df.queryExecution.executedPlan) { case scan: CometNativeScanExec 
=> scan }.nonEmpty,
+      s"expected native field claim 
resolution:\n${df.queryExecution.executedPlan}")
+    checkAnswer(df, expected)
+  }
+
+  for {
+    nested <- Seq(false, true)
+    pruning <- (if (nested) Seq(false, true) else Seq(false))
+    selected <- Seq("both", "id", "name")
+  } {
+    test(
+      s"Parquet field claims projection - nested=$nested pruning=$pruning 
selected=$selected") {
+      withFieldClaimFile(nested) { (path, schema) =>
+        withSQLConf(SQLConf.NESTED_SCHEMA_PRUNING_ENABLED.key -> 
pruning.toString) {
+          def query: DataFrame = {
+            val df = spark.read.schema(schema).parquet(path)
+            val prefix = if (nested) "s." else ""
+            val names = selected match {
+              case "both" => Seq("x", "a")
+              case "id" => Seq("x")
+              case "name" => Seq("a")
+            }
+            df.select(names.map(name => col(prefix + name)): _*)
+          }
+          val expected = fieldClaimReference(query)
+          checkNativeFieldClaimAnswer(query, expected)
+        }
+      }
+    }
+  }
+
+  for {
+    nested <- Seq(false, true)
+    pushdown <- Seq(false, true)
+    predicate <- Seq("missing is null", "missing is not null", "id greater 
than one")
+  } {
+    test(
+      s"Parquet field claims filters - nested=$nested pushdown=$pushdown 
predicate=$predicate") {
+      withFieldClaimFile(nested) { (path, schema) =>
+        withSQLConf(
+          SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED.key -> "true",
+          CometConf.COMET_PARQUET_ROW_FILTER_PUSHDOWN_ENABLED.key -> 
pushdown.toString) {
+          def query: DataFrame = {
+            val df = spark.read.schema(schema).parquet(path)
+            val prefix = if (nested) "s." else ""
+            val x = col(prefix + "x")
+            val a = col(prefix + "a")
+            val condition = predicate match {
+              case "missing is null" => a.isNull
+              case "missing is not null" => a.isNotNull
+              case "id greater than one" => x > 1L
+            }
+            df.where(condition).select(x, a)
+          }
+          val expected = fieldClaimReference(query)
+          val values = predicate match {
+            case "missing is null" => Seq(1L, 2L, 3L)
+            case "missing is not null" => Seq.empty[Long]
+            case "id greater than one" => Seq(2L, 3L)
+          }
+          assert(expected.sortBy(_.toString) == values.map(value => Row(value, 
null)))
+          val df = query
+          checkNativeFieldClaimAnswer(df, expected)
+          if (!nested && pushdown && predicate == "id greater than one") {
+            val scans = collect(df.queryExecution.executedPlan) {
+              case scan: CometNativeScanExec =>
+                scan
+            }
+            assert(
+              scans.exists(scan =>
+                scan.metrics("pushdown_rows_pruned").value +
+                  scan.metrics("pushdown_rows_matched").value > 0))
+          }
+        }
+      }
+    }
+  }
+
+  for {
+    nested <- Seq(false, true)
+    reverse <- Seq(false, true)
+    kind <- Seq("struct", "array", "map")
+  } {
+    test(s"Parquet field claims complex source - nested=$nested 
reverse=$reverse kind=$kind") {
+      val (dataType, values): (DataType, Seq[Any]) = kind match {
+        case "struct" =>
+          (new StructType().add("v", LongType), Seq(Row(11L), Row(null), null))
+        case "array" =>
+          (
+            ArrayType(LongType, containsNull = true),
+            Seq(Seq(11L, null, 33L), Seq.empty[Long], null))
+        case "map" =>
+          (
+            MapType(StringType, LongType, valueContainsNull = true),
+            Seq(Map("k" -> 11L, "nil" -> null), Map.empty[String, Long], null))
+      }
+      val id1 = new MetadataBuilder().putLong("parquet.field.id", 1L).build()
+      val id10 = new MetadataBuilder().putLong("parquet.field.id", 10L).build()
+      def wrapSchema(fields: StructType): StructType =
+        if (nested) StructType(Seq(StructField("s", fields, nullable = true, 
id10))) else fields
+      def wrapRow(fields: Row): Row = if (nested) Row(fields) else fields
+      val writeSchema =
+        wrapSchema(StructType(Seq(StructField("A", dataType, nullable = true, 
id1))))
+      val fields = Seq(
+        StructField("x", dataType, nullable = true, id1),
+        StructField("a", dataType, nullable = true))
+      val readSchema = wrapSchema(StructType(if (reverse) fields.reverse else 
fields))
+      val expected =
+        values.map(value => wrapRow(if (reverse) Row(null, value) else 
Row(value, null)))
+      withSQLConf(
+        SQLConf.CASE_SENSITIVE.key -> "false",
+        SQLConf.PARQUET_FIELD_ID_WRITE_ENABLED.key -> "true",
+        SQLConf.PARQUET_FIELD_ID_READ_ENABLED.key -> "true") {
+        withTempPath { path =>
+          withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+            spark
+              .createDataFrame(
+                spark.sparkContext.parallelize(values.map(value => 
wrapRow(Row(value))), 1),
+                writeSchema)
+              .write
+              .parquet(path.getCanonicalPath)
+          }
+          def query: DataFrame = 
spark.read.schema(readSchema).parquet(path.getCanonicalPath)
+          withSQLConf(
+            CometConf.COMET_ENABLED.key -> "false",
+            SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+            val df = query
+            val scans = collect(df.queryExecution.executedPlan) { case scan: 
FileSourceScanExec =>
+              scan
+            }
+            assert(scans.nonEmpty && scans.forall(!_.supportsColumnar))
+            checkAnswer(df, expected)
+          }
+          checkNativeFieldClaimAnswer(query, expected)
+        }
+      }
+    }
+  }
+
+  for {
+    nested <- Seq(false, true)
+    reverse <- Seq(false, true)
+    count <- Seq(2, 3, 4)
+  } {
+    test(
+      s"Parquet field claims - repeated requested Parquet ids match parquet-mr 
- nested=$nested reverse=$reverse count=$count") {
+      val id10 = new MetadataBuilder().putLong("parquet.field.id", 10L).build()
+      def wrapSchema(fields: StructType): StructType =
+        if (nested) StructType(Seq(StructField("s", fields, nullable = true, 
id10)))
+        else fields
+      def wrapRow(fields: Row): Row = if (nested) Row(fields) else fields
+
+      val fields = Seq("x", "y", "z", "w").take(count).map(name => 
withFieldId(name, 1))
+      val readSchema = wrapSchema(StructType(if (reverse) fields.reverse else 
fields))
+      val writeSchema = wrapSchema(StructType(Seq(withFieldId("A", 1))))
+      val expected = Seq(11L, 22L, 33L).map(value =>
+        wrapRow(Row.fromSeq(Seq.fill[Any](count - 1)(null) :+ value)))
+      withSQLConf(
+        SQLConf.CASE_SENSITIVE.key -> "false",
+        SQLConf.PARQUET_FIELD_ID_WRITE_ENABLED.key -> "true",
+        SQLConf.PARQUET_FIELD_ID_READ_ENABLED.key -> "true",
+        SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "false") {
+        withTempPath { path =>
+          withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+            val rows = Seq(11L, 22L, 33L).map(value => wrapRow(Row(value)))
+            spark
+              .createDataFrame(spark.sparkContext.parallelize(rows, 1), 
writeSchema)
+              .write
+              .parquet(path.getCanonicalPath)
+          }
+          // ParquetRowConverter builds its requested-id-to-index map with 
toMap, so the last
+          // field with the repeated id owns the value. Check the Spark result 
in both orders.
+          val sparkRows = fieldClaimReference {
+            spark.read.schema(readSchema).parquet(path.getCanonicalPath)
+          }
+          assert(sparkRows.sortBy(_.toString) == expected.sortBy(_.toString))
+          withSQLConf(
+            CometConf.COMET_ENABLED.key -> "true",
+            CometConf.COMET_SCAN_ALLOW_DISABLED_PARQUET_VECTORIZED_READER.key 
-> "true") {
+            val df = 
spark.read.schema(readSchema).parquet(path.getCanonicalPath)
+            // Keep the existing JVM guard: native matching is tested 
separately by the Rust
+            // Parquet roundtrip, which bypasses this guard and exercises the 
actual converter.
+            val plan = df.queryExecution.executedPlan
+            assert(collect(plan) { case scan: CometNativeScanExec =>
+              scan
+            }.isEmpty)
+            val reasons = new ExtendedExplainInfo().getFallbackReasons(plan)
+            assert(reasons.exists(_.contains("duplicate Parquet field ids")), 
s"$reasons\n$plan")
+            assert(df.collect().toSeq.sortBy(_.toString) == 
sparkRows.sortBy(_.toString))
+          }
+        }
+      }
+    }
+  }
+
+  for {
+    nested <- Seq(false, true)
+    offheap <- Seq(false, true)
+  } {
+    test(
+      s"Parquet field claims - issue 6994 original reproduction - 
nested=$nested offheap=$offheap") {
+      val id1 = new MetadataBuilder().putLong("parquet.field.id", 1L).build()
+      val id10 = new MetadataBuilder().putLong("parquet.field.id", 10L).build()
+      def wrapSchema(fields: StructType): StructType =
+        if (nested) StructType(Seq(StructField("s", fields, nullable = true, 
id10)))
+        else fields
+      def wrapRow(fields: Row): Row = if (nested) Row(fields) else fields
+
+      val writeSchema =
+        wrapSchema(StructType(Seq(StructField("A", IntegerType, nullable = 
true, id1))))
+      val readSchema = wrapSchema(
+        StructType(
+          Seq(
+            StructField("x", IntegerType, nullable = true, id1),
+            StructField("a", IntegerType, nullable = true))))
+      val expected = (1 to 3).map(value => wrapRow(Row(value, null)))
+
+      withSQLConf(
+        SQLConf.CASE_SENSITIVE.key -> "false",
+        SQLConf.PARQUET_FIELD_ID_WRITE_ENABLED.key -> "true",
+        SQLConf.PARQUET_FIELD_ID_READ_ENABLED.key -> "true",
+        "spark.sql.parquet.enableNestedColumnVectorizedReader" -> "true",
+        "spark.sql.columnVector.offheap.enabled" -> offheap.toString) {
+        withTempPath { path =>
+          withSQLConf(CometConf.COMET_ENABLED.key -> "false") {
+            val rows = (1 to 3).map(value => wrapRow(Row(value)))
+            spark
+              .createDataFrame(spark.sparkContext.parallelize(rows, 1), 
writeSchema)
+              .write
+              .parquet(path.getCanonicalPath)
+          }
+          for ((reader, comet, vectorized) <- Seq(
+              ("Spark vectorized", false, true),
+              ("Spark parquet-mr", false, false),
+              ("Comet native", true, true))) {
+            withSQLConf(
+              CometConf.COMET_ENABLED.key -> comet.toString,
+              SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> 
vectorized.toString) {
+              val df = 
spark.read.schema(readSchema).parquet(path.getCanonicalPath)
+              val plan = df.queryExecution.executedPlan
+              val native = collect(plan) { case scan: CometNativeScanExec => 
scan }.nonEmpty
+              assert(native == comet, s"unexpected scan for $reader:\n$plan")
+              if (!comet) {
+                val scans = collect(plan) { case scan: FileSourceScanExec => 
scan }
+                assert(scans.nonEmpty && scans.forall(_.supportsColumnar == 
vectorized))
+              }
+              val rows = df.collect().toSeq
+              info(
+                s"Spark ${spark.version}, nested=$nested, offheap=$offheap, 
$reader: ${rows.mkString(", ")}")

Review Comment:
   [P2] The vectorized off-heap case formats every collected row into a 
ScalaTest `info` event. Issue #6994 documents that the losing field can contain 
stale JVM memory, so a passing test can write those values to CI output. Keep 
the plan assertion for that reader and move collection and row formatting 
inside the branches that check defined results.
   
   Evidence: `df.collect()` and `rows.mkString` both run before the `if (comet 
|| !vectorized)` guard. Spark 4.1.3 honors the test’s off-heap setting for task 
reads. The suite runs in the Linux and macOS workflows, and the ScalaTest 
configuration writes to stdout and `SparkTestSuite.txt`. [Reader behavior 
documented in #6994](https://github.com/apache/datafusion-comet/issues/6994)



##########
native/core/src/parquet/field_resolver.rs:
##########
@@ -204,6 +241,55 @@ impl<'a> FieldResolver<'a> {
             )),
         }
     }
+
+    /// Resolve claims in file order, with id matches ahead of shadows and 
name matches.
+    /// Repeated requested ids keep the last claimant, as Spark parquet-mr 
does. Duplicate file ids are
+    /// checked across the whole requested group, including unprojected root 
fields (#7006).
+    /// Name ambiguity is left to the caller: the root checks referenced 
columns only, whereas
+    /// nested conversion validates every requested field in 
[`Self::resolve_all`].
+    pub(crate) fn file_claims(&self) -> DataFusionResult<Vec<FieldClaim>> {
+        let mut claims = vec![FieldClaim::Unchanged; self.file.len()];
+        for requested in 0..self.requested.len() {
+            match self.select(requested)? {
+                Selection::ById(matched) => {
+                    if let Some(file) = matched {
+                        claims[file].claim(FieldClaim::IdMatch(requested));
+                    }
+                    for &file in self.name_matches(requested) {
+                        claims[file].claim(FieldClaim::Shadowed);
+                    }
+                }
+                Selection::ByName(matches) => {
+                    for &file in matches {
+                        claims[file].claim(FieldClaim::NameMatch(requested));
+                    }
+                }
+            }
+        }
+        Ok(claims)
+    }
+
+    /// Resolve a nested group in requested order, giving each file field to 
its strongest
+    /// claimant only. Missing and losing fields read NULL. Validate 
independent selections
+    /// first, in requested order, so arbitration cannot hide ambiguity or 
change which of
+    /// several invalid selections reports its error first.
+    pub(crate) fn resolve_all(&self) -> DataFusionResult<Vec<Option<usize>>> {
+        let mut resolved = (0..self.requested.len())
+            .map(|requested| self.resolve(requested))
+            .collect::<DataFusionResult<Vec<_>>>()?;
+        let claims = self.file_claims()?;

Review Comment:
   [P2] `resolve_all()` selects every requested child through `resolve()` and 
selects it again through `file_claims()`. A nested conversion repeats the ID 
parsing and lookups on every batch, including when every child has a unique 
match. Record claims from the first validated selection, preserving 
requested-order ambiguity errors, then derive the output indices from the 
owners.
   
   Evidence: An optimized, CPU-pinned probe decoded 32,768 rows with 64 
constant `INT32` children, unique IDs and renamed targets. At 64 rows per 
batch, converter CPU rose from 27.8 to 33.1 µs per batch. Decoding plus 
conversion was 3.6–8.7% slower in all nine alternating base/head pairs, with a 
6.8% median increase and identical output. A single-selection variant lowered 
the combined cost by 5.5% against head.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to