Skip to content
Closed
1 change: 1 addition & 0 deletions docs/sql-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ license: |

- Since Spark 4.4, quoted Variant path keys decode `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX` (including valid surrogate pairs), and escapes of the enclosing quote (`\'` or `\"`). Previously, backslashes were literal, so existing paths can match different keys or become invalid. For example, use `$['a\\']` instead of `$['a\']`. Unknown escapes and unpaired surrogate escapes stay literal. Dot-notation keys are unchanged. To keep the old behavior, set `spark.sql.legacy.variantPathBackslashAsLiteral` to `true` before creating the query.
- Since Spark 4.4, `spark.sql.execution.arrow.pythonUDF.columnarInput.enabled` is `false` by default: Arrow-based Python UDFs read rows from an Arrow-backed upstream operator, such as a Data Source V2 scan or the Arrow cache, as before Spark 4.2. With columnar input, they kept the columns that they pass through until the results of their batch came back from Python, and failed or returned wrong results when the operator reused or released the vectors of the batch in the meantime. To enable columnar input again, set `spark.sql.execution.arrow.pythonUDF.columnarInput.enabled` to `true`.
- Since Spark 4.4, the conversion of columnar batches to rows releases each batch with `ColumnVector.closeIfFreeable` once its rows are consumed also when it is not whole-stage code-generated, e.g. when the query has more than `spark.sql.codegen.maxFields` fields or `spark.sql.codegen.wholeStage` is `false`, as the generated code has done since Spark 3.5.4 and 4.0. A `ColumnVector` that a Data Source V2 source reuses across batches, such as an `ArrowColumnVector` over a `VectorSchemaRoot` that is refilled for every batch, must override `closeIfFreeable` to do nothing.
- Since Spark 4.4, `spark.sql.charVarchar.standardSemantics.enabled` (default `false`) is a preview feature that may change in future releases. When true, it enables first-class `CHAR(n)` and `VARCHAR(n)` semantics throughout schemas and query plans: `CHAR` -> `VARCHAR` -> `STRING` form a type-precedence family, explicit casts can return constrained types, least-common-type resolution for `COALESCE`/`CASE`/`UNION` can return `CHAR` or `VARCHAR`, and string expressions that transform their input return `STRING`. Assignment and reads enforce the declared length, padding `CHAR` to `n` characters and raising `EXCEED_LIMIT_LENGTH` when non-space content does not fit. Comparing a CHAR column with a string literal no longer pads the literal, so `WHERE char_col = 'ab'` does not match a stored `'ab '`. The default remains the Spark 3.1 table-schema overlay on `STRING`. This opt-in mode is preferred over the experimental `spark.sql.preserveCharVarcharTypeInfo`, which instead keeps constrained types through transforming expressions. See [Character String Types](sql-ref-character-string-types.html) for the preview semantics; the entries below note area-specific behavior that changed. Later CHAR/VARCHAR bullets that do not name `preserveCharVarcharTypeInfo` apply when the preview flag is true, except `from_avro` value assignment, which also runs when only `preserveCharVarcharTypeInfo` is true.
- Since Spark 4.4, when `spark.sql.charVarchar.standardSemantics.enabled` is true, `from_json` length-checks JSON object names used as `MAP<CHAR(n), _>` or `MAP<VARCHAR(n), _>` keys without padding or trimming them. A `CHAR(n)` key must already be exactly `n` characters, and a `VARCHAR(n)` key must already be at most `n` characters; duplicate names are kept, exactly as for STRING keys, and `spark.sql.mapKeyDedupPolicy` is not applied. A key that fails the check makes the enclosing map a bad record: in `PERMISSIVE` mode the nearest enclosing struct field becomes `null` (its sibling fields are preserved), and if the map is inside an array or another map, that whole array or map becomes `null`; in `FAILFAST` mode parsing fails with `UNSUPPORTED_JSON_CHAR_VARCHAR_MAP_KEY` (SQLSTATE `0A000`) as the cause of `MALFORMED_RECORD_IN_PARSING`. The same parser-level check runs for any JSON scan whose reader schema carries a CHAR/VARCHAR map key, including catalog JSON tables, since under standard semantics those keys stay CHAR/VARCHAR all the way to the scan: an off-width key becomes a bad record there too, rather than being padded or raising `EXCEED_LIMIT_LENGTH`. Unlike `from_json`, keys that pass the check are then rebuilt read-side, so on the scan path `spark.sql.mapKeyDedupPolicy` does apply and a duplicate name can raise `DUPLICATED_MAP_KEY`. JSON file scans, including scans inside persisted views, read the flag from the executing session. The optimizer no longer folds `from_json(to_json(m))` to `m` when that schema has a CHAR/VARCHAR map key, so the length check still runs on the round trip.
- Since Spark 4.4, `schema_of_json`, `schema_of_csv`, and `schema_of_xml` accept CHAR/VARCHAR document arguments when CHAR/VARCHAR are first-class. In Spark 4.3, with `spark.sql.preserveCharVarcharTypeInfo` on, they rejected those arguments because the child type was not STRING.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.spark.sql.execution.vectorized;

import org.apache.arrow.vector.ValueVector;

import org.apache.spark.sql.vectorized.ArrowColumnVector;

/**
* An {@link ArrowColumnVector} whose producer may release or overwrite it after returning it, for
* example by loading every batch into the same Arrow vectors. A consumer must not hold it past the
* next batch.
*
* <p>Like the vectors that {@link WritableColumnVector} and {@link ConstantColumnVector} reuse
* across batches, the producer releases it: {@link #closeIfFreeable()}, which a consumer calls
* once it is done with a batch, is a no-op, and the producer frees the vector with
* {@link #close()}. This covers the vector itself: the vectors that {@link #getChild(int)}
* returns for the fields of a struct are plain {@link ArrowColumnVector}s, which consumers do not
* release on their own.
*/
public class ReusedArrowColumnVector extends ArrowColumnVector {

public ReusedArrowColumnVector(ValueVector vector) {
super(vector);
}

@Override
public void closeIfFreeable() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

4. [Nit] Only the top-level vector is covered, not the child columns of a struct

getChild returns the plain ArrowColumnVectors that ArrowColumnVector builds for the fields of a struct (ArrowColumnVector.java:163 and 257), so closeIfFreeable on one of them still closes a vector of the producer's root. No consumer in Spark releases a child column on its own today, so this is only about the Javadoc above, which says that a consumer's closeIfFreeable leaves the vector to its producer. Could the Javadoc say that this covers the top-level vector, or could getChild return reused vectors too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, I made the Javadoc say that it covers the vector itself, and that the vectors getChild returns for the fields of a struct are plain ArrowColumnVectors, which consumers do not release on their own. Returning reused child vectors would need a change to ArrowColumnVector, which builds them in package-private code.

// no-op: the producer owns the vector and frees it in `close`.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import org.apache.spark.sql.execution.vectorized.{OffHeapColumnVector, OnHeapCol
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.vectorized.ColumnarBatch
import org.apache.spark.util.ArrayImplicits._
import org.apache.spark.util.CompletionIterator

class ColumnarToRowEvaluatorFactory(
childOutput: Seq[Attribute],
Expand All @@ -44,10 +45,14 @@ class ColumnarToRowEvaluatorFactory(
inputs: Iterator[ColumnarBatch]*): Iterator[InternalRow] = {
assert(inputs.length == 1)
val toUnsafe = UnsafeProjection.create(childOutput, childOutput)
// Releases each batch once all its rows have been returned, before the next batch is
// fetched, as the generated code of ColumnarToRowExec does in advanceBatch: flatMap checks
// the iterator of the current batch, which runs its completion, before the next batch.
inputs.head.flatMap { input =>
numInputBatches += 1
numOutputRows += input.numRows()
input.rowIterator().asScala.map(toUnsafe)
CompletionIterator[InternalRow, Iterator[InternalRow]](

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

1. [Low] When ColumnarToRowExec is not compiled, this now releases batches that nothing released before

The description says that a reused vector whose closeIfFreeable is not a no-op "is now closed by these consumers as well, as it already is by the generated code of ColumnarToRowExec since SPARK-50235", and your reply says that such a source "is broken today regardless of this path". That holds only where ColumnarToRowExec is compiled. CollapseCodegenStages leaves it out when its input or output has more than spark.sql.codegen.maxFields (100) fields, nested ones included (WholeStageCodegenExec.scala:1214-1218), and a stage that fails to compile or exceeds spark.sql.codegen.hugeMethodLimit falls back to child.execute() (WholeStageCodegenExec.scala:1034 and 1046). In those cases, and with spark.sql.codegen.wholeStage=false, doExecute (Columnar.scala:84-97) runs this evaluator, which released nothing so far.

So a DSv2 source that refills one VectorSchemaRoot per batch and wraps it in plain ArrowColumnVectors works today on a table with more than 100 fields, and fails after this change once it has a struct column. closeIfFreeable closes the struct vector, which drops its children, so the next VectorLoader.load into that root fails with should have as many children as in the schema, as ArrowOutputProcessorSuite shows for the Python output. The wrapper's childColumns is null by then as well (ArrowColumnVector.java:63-69). Could the user-facing section say that this now also applies where ColumnarToRowExec is not compiled, e.g. for more than spark.sql.codegen.maxFields fields? A line in the SQL migration guide may fit too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, you're right that this holds only where ColumnarToRowExec is compiled. I added a line to the 4.4 section of the SQL migration guide in dd1c0fe: the conversion of columnar batches to rows now releases each batch with closeIfFreeable also when it is not whole-stage code-generated, e.g. with more than spark.sql.codegen.maxFields fields or with spark.sql.codegen.wholeStage=false, as the generated code has done since 3.5.4 and 4.0, so a ColumnVector that a Data Source V2 source reuses across batches must override closeIfFreeable to do nothing. The user-facing section of the description says so too.

input.rowIterator().asScala.map(toUnsafe), input.closeIfFreeable())
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,28 +117,13 @@ case class ArrowEvalPythonExec(
// accept columnar input to avoid the ColumnarToRow -> ArrowWriter round-trip.
// The Arrow FieldVectors are extracted directly from ArrowColumnVector and
// serialized to IPC, bypassing the row-based ArrowWriter conversion.
// This node then outputs only columnar batches, so the planner inserts a
// ColumnarToRowExec above it for a row-based parent, which releases each

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

2. [Low] In pipelined mode, the Arrow columnar path now releases its input batches on another thread than the one reading them

On this path, each output batch carries the columns of its input batch (ColumnarArrowEvalPythonEvaluatorFactory.scala:226), so the ColumnarToRowExec described here calls closeIfFreeable on them from the task thread. With spark.python.udf.pipelined.enabled=true, the writer pulls the upstream iterator on its own pool thread (PythonRunner.scala:1577 and 1594), so PartitionReader.next() for the later batches (DataSourceRDD.scala:240) runs on that thread at the same time. Before this PR nothing released these batches, and advanceBatch, the non-codegen evaluator and the row queue paths all release on the thread that reads. ArrowBackedPartitionReader synchronizes allBatches (ArrowBackedDataSourceV2.scala:285 and 313) because its closeIfFreeable and next() can now run on these two threads.

Arrow allocators are thread-safe, but a source whose vectors return their buffers to a pool of the reader, or update other reader state when closed, now has to be thread-safe as well. Columnar input and pipelined mode are both off by default, so documenting it may be enough. Could the class doc of ColumnarArrowEvalPythonEvaluatorFactory (L45-53), which already says that the batches must stay valid until their results come back, also say that they are then released from the task thread while the writer may be reading the next ones?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, added to the class doc in dd1c0fe: the consumer of the output releases the pass-through columns with closeIfFreeable on the task thread, and with spark.python.udf.pipelined.enabled the Python writer reads the next input batches on its own thread meanwhile, so the upstream operator must allow its vectors to be released while it returns the next batches.

// batch once its rows are consumed. Otherwise it reads rows from its child,
// even from one that also supports columnar, such as the scan of the Arrow
// cache. Either way, the choice is fixed when the plan is built.
override def supportsColumnar: Boolean =
child.supportsColumnar && conf.arrowPySparkUDFColumnarInputEnabled
override def supportsRowBased: Boolean = true

override protected def doExecute(): RDD[InternalRow] = {
// Checks the configuration too, through `supportsColumnar`: a child that also returns rows,
// such as the scan of the Arrow cache, gets no ColumnarToRowExec when columnar input is
// disabled, and still supports columnar.
if (supportsColumnar) {
// Columnar path: delegate to doExecuteColumnar, flatten to
// UnsafeRow. ColumnarBatchRow from rowIterator() is NOT
// UnsafeRow, and downstream operators (e.g., outer
// EvalPythonExec) may cast to UnsafeRow.
doExecuteColumnar().mapPartitionsInternal { batchIter =>
val toUnsafe = UnsafeProjection.create(schema)
batchIter.flatMap(_.rowIterator().asScala.map(toUnsafe))
}
} else {
// Row-based path: unchanged.
super.doExecute()
}
}

override protected def doExecuteColumnar(): RDD[ColumnarBatch] = {
val inputRDD = child.executeColumnar()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,12 @@ import org.apache.spark.util.Utils
* reuse or release the vectors of a batch once it is asked for the next
* one. So `spark.sql.execution.arrow.pythonUDF.columnarInput.enabled`
* is disabled by default. Output is produced by columnar combining:
* passThruCols ++ resultCols -> ColumnarBatch.
* passThruCols ++ resultCols -> ColumnarBatch, so the consumer of the
* output releases the pass-through columns with closeIfFreeable on the
* task thread. With `spark.python.udf.pipelined.enabled`, the Python
* writer reads the next input batches on its own thread meanwhile, so
* the upstream operator must allow its vectors to be released while it
* returns the next batches.
*
* 2. '''Non-Arrow columnar path''' (UDF inputs are simple column refs
* BUT columns are NOT [[ArrowColumnVector]]): Non-Arrow columnar
Expand All @@ -63,6 +68,9 @@ import org.apache.spark.util.Utils
* [[BasicPythonArrowInput]]. Uses [[HybridRowQueue]] for pass-through
* and converts joined rows back to [[ColumnarBatch]].
*
* Paths 2 and 3 copy what they need from an input batch, so they release
* it with closeIfFreeable once it has been written to Python.
*
* TODO: Add a physical plan rule that inserts a ProjectExec before
* ArrowEvalPythonExec to pre-evaluate complex UDF input expressions
* into simple column references. This would eliminate path 3.
Expand Down Expand Up @@ -255,12 +263,12 @@ private[python] class ColumnarArrowEvalPythonEvaluatorFactory(

val resultIter = if (inputColumnIndices.isDefined) {
// Path 2: simple column refs, non-Arrow vectors.
val bufferedIter = inputIter.map { batch =>
val bufferedIter = new WrittenBatchReleasingIterator(inputIter, { batch =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

3. [Low] No test takes path 2, so this release is untested

The new UDF test covers the Arrow columnar path (name) and the row fallback path (concat(name, '_')), but not this one. ArrowBackedPartitionReader always puts an ArrowColumnVector first (ArrowBackedDataSourceV2.scala:306-310), so isArrow (L155-158) is true for every batch of the test source, and simple column references always take path 1. If this iterator released a batch in next(), before the writer serializes it, or stopped releasing at all, every test would still pass. Could ArrowBackedPartitionReader get an option that wraps id in a release-counting ColumnVector that is not an ArrowColumnVector, delegating to the Arrow vector, so that arrow_release_udf(name) takes this path with the same value and counter assertions?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, added in dd1c0fe: the test source has an arrowIdColumn option that wraps id in a release-counting ColumnVector that delegates to the Arrow vector but is not an ArrowColumnVector, and the UDF test runs arrow_release_udf(name) over it with the same value and counter assertions, with whole-stage codegen on and off. It also checks the path from the output batches, which carry the input columns only on the Arrow columnar path. If this iterator stops releasing, the test fails with 0 released batches out of 20, and if it releases a batch before the writer serializes it, the UDF results come back as null.

batch.rowIterator().asScala.foreach { row =>
queue.add(unsafeProj(row).copy())
}
batch
}
true
})
val pyRunner =
new ColumnarArrowPythonWithNamedArgumentRunner(
pyFuncs, evalType, argMetas, udfInputSchema,
Expand All @@ -274,7 +282,9 @@ private[python] class ColumnarArrowEvalPythonEvaluatorFactory(
val projection = MutableProjection.create(
allInputs, childOutput)
projection.initialize(context.partitionId())
val batchIter = inputIter.map { batch =>
// The rows of a batch are written to Python before the writer asks
// for the next batch, which releases it.
val batchIter = new WrittenBatchReleasingIterator(inputIter, _ => true).map { batch =>
batch.rowIterator().asScala.map { row =>
queue.add(unsafeProj(row).copy())
projection(row)
Expand Down Expand Up @@ -343,6 +353,41 @@ private[python] class ColumnarArrowEvalPythonEvaluatorFactory(
}
}

/**
* Hands the input batches to the Python writer. `enqueue` is called on each
* batch before the writer gets it, and returns whether the batch should be
* released with closeIfFreeable once it has been written: the writer is done
* with a batch when it asks for the next one or reaches the end.
*/
private[python] class WrittenBatchReleasingIterator(
input: Iterator[ColumnarBatch],
enqueue: ColumnarBatch => Boolean)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit (P3): Both call sites always return true here: Path 2's callback copies the rows and returns true, and Path 3 passes _ => true. Since the class is private[python] with no other callers, the if (enqueue(batch)) false arm is unreachable, and readers have to reason about a "keep this batch unreleased" mode that never occurs. Could this take a ColumnarBatch => Unit callback (or none, for Path 3) and always set written = batch, with the class doc updated to match?

Verification:

  • Inspection: The iterator's API exposes no release predicate, and both call sites compile against the simplified signature.
  • Behavior: Arrow Python UDF results stay correct on the simple-column and complex-expression row-queue paths.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks. In this PR both callers release every batch, but #59582, which is stacked on this one, uses the false case: on the Arrow columnar path, a batch whose columns are held is not released once it is written, but by the consumer of the output batch. I kept the predicate here so that its signature does not change again there. Happy to simplify it here and add it back in #59582 if you prefer.

extends Iterator[ColumnarBatch] {

private var written: ColumnarBatch = _

private def releaseWritten(): Unit = {
if (written != null) {
written.closeIfFreeable()
written = null
}
}

override def hasNext: Boolean = {
releaseWritten()
input.hasNext
}

override def next(): ColumnarBatch = {
releaseWritten()
val batch = input.next()
if (enqueue(batch)) {
written = batch
}
batch
}
}

/** Iterator wrapper that allows peeking at the first element. */
private[python] class PeekableIterator[T](underlying: Iterator[T])
extends Iterator[T] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ import org.apache.arrow.vector.ipc.ArrowStreamReader
import org.apache.spark.{SparkEnv, TaskContext}
import org.apache.spark.api.python.{BasePythonRunner, PythonWorker, PythonWorkerHandle, SpecialLengths}
import org.apache.spark.sql.execution.metric.SQLMetric
import org.apache.spark.sql.execution.vectorized.ReusedArrowColumnVector
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.StructType
import org.apache.spark.sql.util.ArrowUtils
import org.apache.spark.sql.vectorized.{ArrowColumnVector, ColumnarBatch, ColumnVector}
import org.apache.spark.sql.vectorized.{ColumnarBatch, ColumnVector}

/**
* A trait that can be mixed-in with [[BasePythonRunner]]. It implements the logic from
Expand Down Expand Up @@ -170,12 +171,18 @@ trait ArrowOutputProcessor {
def close(): Unit
}

/**
* Loads every batch from Python into the reader's root and returns batches over the same vectors,
* so the vectors of a batch are only valid until the next one is loaded. They are
* [[ReusedArrowColumnVector]]s: a consumer does not hold them past the next batch, and its
* `closeIfFreeable` leaves them to this processor, which closes them in `close`.
*/
class ArrowOutputProcessorImpl(reader: ArrowStreamReader, pythonMetrics: Map[String, SQLMetric])
extends ArrowOutputProcessor {
protected val root = reader.getVectorSchemaRoot()
protected val schema: StructType = ArrowUtils.fromArrowSchema(root.getSchema())
private val vectors: Array[ColumnVector] = root.getFieldVectors().asScala.map { vector =>
new ArrowColumnVector(vector)
new ReusedArrowColumnVector(vector)
}.toArray[ColumnVector]

protected var rowCount = -1
Expand Down Expand Up @@ -235,12 +242,15 @@ abstract class BaseSliceArrowOutputProcessor(
batch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Pre-existing since SPARK-51769: the batch returned here repeats rows at the end of every Python batch that SliceRecordsArrowOutputProcessorImpl slices. Once remainingRows <= arrowMaxRecordsPerOutputBatch, its getRoot (L323-334) returns the whole root instead of the remaining rows, and L248 sets numRows to the full row count. With maxRecordsPerOutputBatch=3, a 10-row batch comes out as rows 0-2, 3-5 and 6-8, then 0-9 again: 19 rows instead of 10. test_different_output_length in test_arrow_map.py, run by MapInArrowWithOutputArrowBatchSlicingRecordsTests, would get 199 rows for 100, but it compares sets, so it passes. The loop in ArrowOutputProcessorSuite with new SliceRecordsArrowOutputProcessorImpl(reader, metrics(), 2) would show it as rows 0, 1, 0, 1, 2 for the first batch. Could getRoot return root.slice(currentRowIdx, remainingRows) once currentRowIdx > 0, here or in a separate JIRA?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, this is fixed in #59616 (SPARK-60367), together with the closing of the reader's root from your comment on L252.

}

// The vectors are either those of the reader's root, into which the next batch is loaded, or
// those of a slice of it, which is closed once the next one is produced, so both are reused
// vectors.
protected override def getVectors(root: VectorSchemaRoot): Array[ColumnVector] = {
if (root eq this.root) {
super.getVectors(root)
} else {
root.getFieldVectors.asScala.map { vector =>
new ArrowColumnVector(vector)
new ReusedArrowColumnVector(vector)
}.toArray[ColumnVector]
}
}
Expand Down
Loading