andygrove commented on code in PR #5603:
URL: https://github.com/apache/datafusion-comet/pull/5603#discussion_r3942430315


##########
spark/src/test/resources/sql-tests/expressions/struct/create_named_struct.sql:
##########
@@ -33,3 +33,33 @@ SELECT named_struct('x', 1, 'y', 'hello', 'z', 3.14)
 
 query
 SELECT named_struct('x', a, 'y', 'fixed_val', 'z', c) FROM test_named_struct
+
+-- duplicate names dispatch through Spark codegen while preserving ordinal 
values
+query
+SELECT named_struct('x', a, 'x', b) FROM test_named_struct
+
+-- struct() lowers to CreateNamedStruct and derives duplicate names from 
repeated children
+query
+SELECT struct(a, a) FROM test_named_struct
+
+-- nested duplicate-name structs exercise list and map roots during Arrow 
import
+query
+SELECT array(named_struct('x', a, 'x', b)) FROM test_named_struct
+
+query
+SELECT map('row', named_struct('x', a, 'x', b)) FROM test_named_struct
+
+-- nested structs, three duplicates, and an all-null row
+query
+SELECT named_struct('outer', named_struct('x', a, 'x', b)) FROM 
test_named_struct
+
+query
+SELECT named_struct('x', a, 'x', b, 'x', c) FROM test_named_struct
+
+query
+SELECT named_struct('x', a, 'x', b, 'x', c) FROM test_named_struct WHERE a IS 
NULL
+
+-- construct the duplicate-name struct after a supported primitive-key shuffle 
boundary
+query
+SELECT named_struct('x', a, 'x', b)
+FROM (SELECT /*+ REPARTITION(2, a) */ a, b FROM test_named_struct) shuffled

Review Comment:
   This case does what the comment above it says, which is the problem: the 
`REPARTITION(2, a)` hint is inside the subquery, so `a` and `b` cross the 
exchange as plain `int` and `string` and the struct is built afterwards. 
Nothing in the PR carries a duplicate-name struct *through* a shuffle, which is 
the boundary the `StreamReader` change is for.
   
   Moving the hint outside would cover it:
   
   ```sql
   SELECT /*+ REPARTITION(3) */ s
   FROM (SELECT named_struct('x', a, 'x', b) AS s FROM test_named_struct) 
shuffled
   ```
   
   Worth knowing before you try it that this version currently fails the 
fixture's coverage assertion, for the shuffle-guard reason in my summary. It 
passes once that guard is gone.



##########
spark/src/main/scala/org/apache/comet/vector/NativeUtil.scala:
##########
@@ -322,6 +333,192 @@ class NativeUtil extends AutoCloseable {
 }
 
 object NativeUtil {
+
+  /**
+   * Create a vector whose physical struct children remain positional when the 
exported Arrow
+   * schema contains duplicate names. Arrow's default struct factory indexes 
children by name and
+   * collapses such fields.
+   */
+  private[comet] def createVector(field: Field, allocator: BufferAllocator): 
FieldVector = {
+    val runtimeField = fieldForAllocation(field)
+    createPinnedVector(runtimeField, field, allocator)
+  }
+
+  /**
+   * Preserve Arrow's default allocation path unless a duplicate-name struct 
needs positional
+   * runtime children. This is called for every imported column of every 
native batch.
+   */
+  private[comet] def createVectorForImport(
+      field: Field,
+      allocator: BufferAllocator): FieldVector = {
+    val runtimeField = fieldForAllocation(field)
+    if (runtimeField eq field) {
+      field.createVector(allocator).asInstanceOf[FieldVector]
+    } else {
+      createPinnedVector(runtimeField, field, allocator)
+    }
+  }
+
+  /** Build an IPC root with the same duplicate-safe allocation used by C Data 
imports. */
+  def createVectorSchemaRootForImport(
+      schema: Schema,
+      allocator: BufferAllocator): VectorSchemaRoot = {
+    val fields = schema.getFields
+    val vectors = new ArrayList[FieldVector](fields.size())
+    try {
+      var ordinal = 0
+      while (ordinal < fields.size()) {
+        vectors.add(createVectorForImport(fields.get(ordinal), allocator))
+        ordinal += 1
+      }
+      new VectorSchemaRoot(schema, vectors, 0)
+    } catch {
+      case failure: Throwable =>
+        AutoCloseables.close(failure, vectors)
+        throw failure
+    }
+  }
+
+  /**
+   * Build a C Stream root whose physical and advertised schemas use the same 
duplicate-safe field
+   * names. Arrow's C Data exporter reconstructs nested vectors from the 
advertised schema and
+   * otherwise collapses duplicate struct children before loading the record 
batch.
+   */
+  def createVectorSchemaRootForExport(
+      schema: Schema,
+      allocator: BufferAllocator): VectorSchemaRoot = {
+    val fields = schema.getFields
+    val runtimeFields = new ArrayList[Field](fields.size())
+    val vectors = new ArrayList[FieldVector](fields.size())
+    try {
+      var ordinal = 0
+      while (ordinal < fields.size()) {
+        val runtimeField = fieldForAllocation(fields.get(ordinal))
+        runtimeFields.add(runtimeField)
+        
vectors.add(runtimeField.createVector(allocator).asInstanceOf[FieldVector])
+        ordinal += 1
+      }
+      new VectorSchemaRoot(new Schema(runtimeFields), vectors, 0)
+    } catch {
+      case failure: Throwable =>
+        AutoCloseables.close(failure, vectors)
+        throw failure
+    }
+  }
+
+  private def createPinnedVector(
+      runtimeField: Field,
+      exportField: Field,
+      allocator: BufferAllocator): FieldVector = {
+    exportField.getType match {
+      case _: ArrowType.List | _: ArrowType.LargeList | _: 
ArrowType.FixedSizeList =>
+        val vector = new RenamedListVector(runtimeField, exportField, 
allocator)
+        vector.initializeChildrenFromFields(runtimeField.getChildren)
+        vector
+      case _: ArrowType.Map =>
+        val vector = new RenamedMapVector(runtimeField, exportField, allocator)
+        vector.initializeChildrenFromFields(runtimeField.getChildren)
+        vector
+      case _: ArrowType.Struct =>
+        val vector = new RenamedStructVector(runtimeField, exportField, 
allocator)
+        // Arrow's Field-based StructVector constructor creates children 
through a writer whose
+        // cache lower-cases field names. Build the direct children 
positionally instead so case-
+        // distinct names such as `a` and `A` remain separate physical vectors.
+        vector.initializeChildrenFromFields(runtimeField.getChildren)
+        vector
+      case _ => exportField.createVector(allocator).asInstanceOf[FieldVector]
+    }
+  }
+
+  private def fieldForAllocation(field: Field): Field = {
+    val children = field.getChildren
+    if (children.isEmpty) return field
+
+    val names = field.getType match {
+      case _: ArrowType.Struct if children.size() > 1 => new 
HashSet[String](children.size())

Review Comment:
   The identity fast path works as intended. A scalar column now costs one 
`getChildren().isEmpty()` and goes straight to `field.createVector`, and the 
new `$data$` regression pins that.
   
   One allocation is left on the per-batch path. `names` is a fresh 
`HashSet[String]` for every struct node with more than one child, created on 
every `importVector` call for every complex column, and in the overwhelmingly 
common no-duplicate case it gets filled and discarded having proved nothing. 
For the child counts structs usually have, a linear scan over the 
already-materialised `children` list would avoid it, or the set could be 
created only on the first repeat.
   
   This is second-order next to the `Field` tree that `importField` already 
rebuilds on the same call, so only worth doing if it stays a one-liner.



##########
spark/src/test/scala/org/apache/comet/CometExpressionSuite.scala:
##########
@@ -2474,26 +2474,6 @@ class CometExpressionSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
     }
   }
 
-  test("named_struct with duplicate field names") {

Review Comment:
   Removing this makes sense, since it asserted `ProjectExec` and that is 
precisely the behaviour the PR changes. The input diversity went with it 
though: ten thousand rows, `dictionaryEnabled` both ways, and a literal child 
in `named_struct('a', _1, 'a', 2)`. The new fixture has three rows, no 
dictionary variation, and no duplicate-plus-literal case.
   
   I reran these queries against the branch and they all match, so nothing is 
broken. But a dictionary-encoded string child inside a duplicate-name struct is 
cheap to keep and awkward to notice losing. Would you add a `dictionaryEnabled` 
loop over a duplicate-name struct somewhere, or a `--CONFIG` matrix line on the 
fixture?



##########
spark/src/main/scala/org/apache/spark/sql/comet/execution/arrow/ColumnarBatchArrowReader.scala:
##########
@@ -44,12 +46,68 @@ private[comet] class ColumnarBatchArrowReader(
     source: Iterator[ColumnarBatch])
     extends ArrowReader(allocator) {
 
+  private var cometInitialized = false
+  private var cometClosed = false
+  private var cometRoot: VectorSchemaRoot = _
+  private var cometLoader: VectorLoader = _
+
   override protected def readSchema(): Schema = arrowSchema
 
+  override protected def initialize(): Unit = {
+    cometRoot = NativeUtil.createVectorSchemaRootForExport(readSchema(), 
allocator)

Review Comment:
   This override drops three things `ArrowReader.initialize` did: the per-field 
`DictionaryUtility.toMemoryFormat` call, `originalSchema.getCustomMetadata()`, 
and populating `dictionaries`.
   
   I traced all three and they look safe. 
`CometArrowStream.reconcileStreamSchema` already decodes 
`CometDictionaryVector` columns down to the dictionary's value type, so the 
schema reaching here never carries a `DictionaryEncoding`, and neither 
`Utils.toArrowSchema` nor `reconcileStreamSchema` ever sets schema-level 
metadata, so there is none to lose.
   
   That took a while to establish though, and the commit directly under this 
branch (5552) was specifically about Arrow metadata surviving C Data exports. 
Could a sentence here record why skipping both is correct, so the next person 
does not have to re-derive it?



##########
spark/src/main/scala/org/apache/comet/vector/CometArrowStreamReader.scala:
##########
@@ -0,0 +1,186 @@
+/*
+ * 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.comet.vector
+
+import java.nio.channels.ReadableByteChannel
+import java.util
+
+import scala.collection.JavaConverters._
+
+import org.apache.arrow.memory.BufferAllocator
+import org.apache.arrow.util.AutoCloseables
+import org.apache.arrow.vector.{FieldVector, VectorLoader, VectorSchemaRoot}
+import org.apache.arrow.vector.compression.CompressionCodec
+import org.apache.arrow.vector.dictionary.Dictionary
+import org.apache.arrow.vector.ipc.{ArrowStreamReader, ReadChannel}
+import org.apache.arrow.vector.ipc.message.{ArrowDictionaryBatch, 
ArrowRecordBatch, MessageChannelReader}
+import org.apache.arrow.vector.types.pojo.{Field, Schema}
+import org.apache.arrow.vector.util.{DictionaryUtility, VectorBatchAppender}
+
+/**
+ * Arrow IPC reader that keeps struct children positional when a schema 
contains duplicate names.
+ *
+ * ArrowReader normally allocates each field with `Field.createVector`, which 
indexes direct
+ * struct children by name and collapses duplicates. Reuse NativeUtil's import 
factory here so IPC
+ * and C Data imports have the same physical layout and the ordinary 
no-duplicate path stays
+ * unchanged.
+ */
+final class CometArrowStreamReader(
+    messageReader: MessageChannelReader,
+    allocator: BufferAllocator,
+    compressionFactory: CompressionCodec.Factory)
+    extends ArrowStreamReader(messageReader, allocator, compressionFactory) {
+
+  def this(messageReader: MessageChannelReader, allocator: BufferAllocator) =
+    this(messageReader, allocator, CompressionCodec.Factory.INSTANCE)
+
+  def this(channel: ReadableByteChannel, allocator: BufferAllocator) =
+    this(
+      new MessageChannelReader(new ReadChannel(channel), allocator),
+      allocator,
+      CompressionCodec.Factory.INSTANCE)
+
+  private var cometInitialized = false
+  private var cometResourcesClosed = false
+  private var cometSourceClosed = false
+  private var cometRoot: VectorSchemaRoot = _
+  private var cometLoader: VectorLoader = _

Review Comment:
   This class and `ColumnarBatchArrowReader` now carry the same forty-odd lines 
of shadow state: `cometInitialized`, `cometRoot` and `cometLoader`, plus 
overrides of `ensureInitialized`, `getVectorSchemaRoot`, 
`prepareLoadNextBatch`, `loadRecordBatch`, `lookup`, `getDictionaryIds`, 
`getDictionaryVectors` and `close`.
   
   The reason is good and not at all obvious from reading either file. 
`ArrowReader.root`, `loader` and `initialized` are all private in Arrow 18.3, 
so there is no way to reuse them, and every base method that reads them has to 
be overridden or it silently sees `null` or `false`. I enumerated those methods 
and the override set is complete in both classes, which is the part I most 
expected to be wrong. The class doc here explains the allocation motive but not 
the shadowing one.
   
   Two smaller things suggest the second copy did not get the same pass as the 
first. `getDictionaryIds` throws `IllegalStateException` when uninitialized 
here but calls `ensureInitialized()` in `ColumnarBatchArrowReader`, and Arrow's 
base does neither. And `ColumnarBatchArrowReader.close(closeReadSource: 
Boolean)` ignores its parameter, which is harmless because `closeReadSource()` 
is `()` in that class but reads as an oversight.
   
   Would a shared `private[comet]` base in `org.apache.comet.vector` holding 
the shadow root and loader and the mechanical overrides be worth it? Mostly so 
the private-field constraint has one place to be written down, since that is 
the thing most likely to get lost when someone next touches either class.



-- 
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