sunchao commented on code in PR #56334: URL: https://github.com/apache/spark/pull/56334#discussion_r3539179966
########## sql/core/src/main/scala/org/apache/spark/sql/execution/columnar/ArrowCachedBatchSerializer.scala: ########## @@ -0,0 +1,1634 @@ +/* + * 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.columnar + +import java.io.{ByteArrayInputStream, ByteArrayOutputStream} +import java.nio.channels.Channels + +import scala.jdk.CollectionConverters._ + +import org.apache.arrow.compression.{Lz4CompressionCodec, ZstdCompressionCodec} +import org.apache.arrow.vector.{VectorLoader, VectorSchemaRoot, VectorUnloader} +import org.apache.arrow.vector.compression.{CompressionCodec, NoCompressionCodec} +import org.apache.arrow.vector.ipc.{ReadChannel, WriteChannel} +import org.apache.arrow.vector.ipc.message.{ArrowRecordBatch, MessageSerializer} + +import org.apache.spark.{SparkException, TaskContext} +import org.apache.spark.rdd.RDD +import org.apache.spark.sql.catalyst.InternalRow +import org.apache.spark.sql.catalyst.expressions.Attribute +import org.apache.spark.sql.catalyst.expressions.codegen.UnsafeRowWriter +import org.apache.spark.sql.catalyst.types.DataTypeUtils +import org.apache.spark.sql.catalyst.util.DateTimeUtils +import org.apache.spark.sql.columnar.{CachedBatch, SimpleMetricsCachedBatchSerializer} +import org.apache.spark.sql.errors.ExecutionErrors +import org.apache.spark.sql.execution.arrow.ArrowWriter +import org.apache.spark.sql.internal.SQLConf +import org.apache.spark.sql.types._ +import org.apache.spark.sql.util.ArrowUtils +import org.apache.spark.sql.vectorized.{ArrowColumnVector, ColumnarBatch, ColumnVector} +import org.apache.spark.storage.StorageLevel +import org.apache.spark.unsafe.types.UTF8String +import org.apache.spark.util.Utils + +/** + * A [[CachedBatchSerializer]] that uses Apache Arrow as the cache format. + * + * This serializer: + * - Supports both row-based (InternalRow) and columnar (ColumnarBatch) input + * - Stores each batch as an internal, schema-less encapsulated Arrow RecordBatch message with + * optional compression (zstd/lz4); the schema is reconstructed from the relation's attributes + * on read (see [[ArrowCachedBatch]]) + * - Enables zero-copy columnar reads when output is ColumnarBatch + * - Uses off-heap memory via Arrow allocators during encode/decode + * - Collects per-column statistics for partition pruning + * + * Configuration options: + * - spark.sql.cache.serializer: Set to this class name to enable + * - spark.sql.execution.arrow.maxRecordsPerBatch: Max rows per cached batch + * - spark.sql.execution.arrow.maxBytesPerBatch: Max bytes per cached batch + * - spark.sql.execution.arrow.compression.codec: Compression (none/zstd/lz4) + * - spark.sql.execution.arrow.compression.zstd.level: zstd compression level + * - spark.sql.execution.arrow.cache.prefetch.enabled: Enable background prefetch of the next + * batch while the current one is being consumed + * - spark.sql.inMemoryColumnarStorage.enableVectorizedReader: Enable columnar output + */ +class ArrowCachedBatchSerializer extends SimpleMetricsCachedBatchSerializer { + + // supportsColumnarInput selects the columnar-vs-row input path; it does not gate which schemas + // this serializer accepts. The cache framework has no per-type fallback to another serializer + // (whatever spark.sql.cache.serializer selects handles every cached relation), so returning + // false here only routes input through convertInternalRowToCachedBatch, which is still this + // serializer. Type support is enforced once per partition by checkSupportedSchema below; the + // only real precondition for columnar input is that the plan can produce columnar output, which + // InMemoryRelation already checks via cachedPlan.supportsColumnar before calling this. + override def supportsColumnarInput(schema: Seq[Attribute]): Boolean = true + + override def convertInternalRowToCachedBatch( + input: RDD[InternalRow], + schema: Seq[Attribute], + storageLevel: StorageLevel, + conf: SQLConf): RDD[CachedBatch] = { + ArrowCachedBatchSerializer.checkSupportedSchema(schema) + // Capture config values on driver before RDD transformation + val sparkSchema = DataTypeUtils.fromAttributes(schema) + val maxRecordsPerBatch = conf.arrowMaxRecordsPerBatch + val maxBytesPerBatch = conf.arrowMaxBytesPerBatch + val timeZoneId = conf.sessionLocalTimeZone + val compressionCodecName = conf.arrowCompressionCodec + val compressionLevel = conf.arrowZstdCompressionLevel + + input.mapPartitionsInternal { rowIterator => + new InternalRowToArrowCachedBatchIterator( + rowIterator, + schema, + sparkSchema, + maxRecordsPerBatch, + maxBytesPerBatch, + timeZoneId, + compressionCodecName, + compressionLevel) + } + } + + override def convertColumnarBatchToCachedBatch( + input: RDD[ColumnarBatch], + schema: Seq[Attribute], + storageLevel: StorageLevel, + conf: SQLConf): RDD[CachedBatch] = { + ArrowCachedBatchSerializer.checkSupportedSchema(schema) + // Capture config values on driver before RDD transformation + val sparkSchema = DataTypeUtils.fromAttributes(schema) + val timeZoneId = conf.sessionLocalTimeZone + val compressionCodecName = conf.arrowCompressionCodec + val compressionLevel = conf.arrowZstdCompressionLevel + + input.mapPartitionsInternal { batchIterator => + new ColumnarBatchToArrowCachedBatchIterator( + batchIterator, + schema, + sparkSchema, + timeZoneId, + compressionCodecName, + compressionLevel) + } + } + + override def supportsColumnarOutput(schema: StructType): Boolean = { + // Always support columnar output with Arrow + true + } + + override def vectorTypes(attributes: Seq[Attribute], conf: SQLConf): Option[Seq[String]] = { + Option(Seq.fill(attributes.length)(classOf[ArrowColumnVector].getName)) + } + + override def convertCachedBatchToColumnarBatch( + input: RDD[CachedBatch], + cacheAttributes: Seq[Attribute], + selectedAttributes: Seq[Attribute], + conf: SQLConf): RDD[ColumnarBatch] = { + val cacheSchema = DataTypeUtils.fromAttributes(cacheAttributes) + val selectedSchema = DataTypeUtils.fromAttributes(selectedAttributes) + val columnIndices = + selectedAttributes.map(a => cacheAttributes.map(o => o.exprId).indexOf(a.exprId)).toArray + // Capture config on driver + val timeZoneId = conf.sessionLocalTimeZone + val prefetchEnabled = conf.arrowCachePrefetchEnabled + + input.mapPartitionsInternal { batchIterator => + new ArrowCachedBatchToColumnarBatchIterator( + batchIterator, + cacheSchema, + selectedSchema, + columnIndices, + timeZoneId, + prefetchEnabled) + } + } + + override def convertCachedBatchToInternalRow( + input: RDD[CachedBatch], + cacheAttributes: Seq[Attribute], + selectedAttributes: Seq[Attribute], + conf: SQLConf): RDD[InternalRow] = { + val cacheSchema = DataTypeUtils.fromAttributes(cacheAttributes) + val selectedSchema = DataTypeUtils.fromAttributes(selectedAttributes) + val timeZoneId = conf.sessionLocalTimeZone + + // Calculate column indices for projection + val selectedIndices = selectedAttributes.map { attr => + cacheAttributes.indexWhere(_.exprId == attr.exprId) + }.toArray + + // Check if all selected types can use the fast path. + // Types not handled by ArrowColumnReader must use the fallback path. + val needsFallback = selectedSchema.fields.exists { f => + f.dataType match { + case _: ArrayType | _: StructType | _: MapType => true + case CalendarIntervalType | VariantType | NullType => true + case _: UserDefinedType[_] => true + // Geometry/Geography are represented as an Arrow struct (srid + wkb); the fast-path + // ArrowColumnReader does not handle them, so route them through the fallback. + case _: GeometryType | _: GeographyType => true + // Nanosecond timestamps write a 16-byte TimestampNanosVal payload into the UnsafeRow; + // the fast-path typed readers only write fixed primitives, so use the fallback, which + // reads through ArrowColumnVector.getTimestampNTZNanos/getTimestampLTZNanos. + case _: TimestampNTZNanosType | _: TimestampLTZNanosType => true + case _ => false + } + } + + if (needsFallback) { + // Fall back to columnar-to-row conversion via ColumnarBatch for complex types. + // Use UnsafeProjection to convert ColumnarBatchRow to UnsafeRow. + convertCachedBatchToColumnarBatch(input, cacheAttributes, selectedAttributes, conf) + .mapPartitionsInternal { batchIter => + val toUnsafe = org.apache.spark.sql.catalyst.expressions.UnsafeProjection.create( + selectedSchema) + batchIter.flatMap { batch => + val numRows = batch.numRows() + new Iterator[InternalRow] { + private var rowIdx = 0 + override def hasNext: Boolean = rowIdx < numRows + override def next(): InternalRow = { + val row = batch.getRow(rowIdx) + rowIdx += 1 + toUnsafe(row) + } + } + } + } + } else { + val prefetchEnabled = conf.arrowCachePrefetchEnabled + input.mapPartitionsInternal { batchIterator => + new ArrowCachedBatchToInternalRowIterator( + batchIterator, + cacheSchema, + selectedSchema, + selectedIndices, + timeZoneId, + prefetchEnabled) + } + } + } +} + +/** + * Companion object with shared utility methods for Arrow cache serialization. + */ +private object ArrowCachedBatchSerializer { + + /** + * Run an Arrow write block, translating a CalendarInterval microsecond overflow into a clear + * error. Arrow's IntervalMonthDayNano representation is nanosecond-based, so writing a + * CalendarInterval multiplies its microseconds by 1000 with Math.multiplyExact; Spark allows the + * full Long microsecond domain, so values beyond Long.MaxValue/1000 overflow and otherwise abort + * with an opaque "long overflow" ArithmeticException. The catch is only installed when the schema + * actually contains a CalendarInterval column (hasInterval), so there is no per-row cost and no + * effect on schemas without intervals; the try is entered once per batch, not per row. + */ + def withIntervalOverflowTranslation[T](hasInterval: Boolean)(block: => T): T = { + if (!hasInterval) { + block + } else { + try { + block + } catch { + case e: ArithmeticException => + // Caching a CalendarInterval whose microseconds exceed Arrow's representable range is a + // user-facing limitation (the value cannot be losslessly converted), not an internal + // invariant violation, so use the same structured DATETIME_OVERFLOW condition the + // analogous nanos-timestamp write overflow uses (see + // QueryExecutionErrors.timestampNanosEpochNanosOverflowError). + throw ExecutionErrors.datetimeOverflowError( + "write a CalendarInterval to the Arrow cache: Arrow stores intervals in " + + "nanoseconds, so the microseconds component must fit in " + + s"+/-(Long.MaxValue / 1000). Original error: ${e.getMessage}") + } + } + } + + /** + * Whether the schema contains a CalendarInterval anywhere, including nested inside arrays, + * structs, maps, and UDT sql types (mirroring isSupportedByArrow's traversal): the nested Arrow + * writers reach the same nanosecond conversion, so a nested interval can overflow just like a + * top-level one. DataType.existsRecursively is not used because it does not descend into + * UserDefinedType.sqlType. + */ + def hasCalendarInterval(schema: Seq[Attribute]): Boolean = + schema.exists(attr => typeContainsCalendarInterval(attr.dataType)) + + private def typeContainsCalendarInterval(dt: DataType): Boolean = dt match { + case CalendarIntervalType => true + case ArrayType(elementType, _) => typeContainsCalendarInterval(elementType) + case StructType(fields) => fields.exists(f => typeContainsCalendarInterval(f.dataType)) + case MapType(keyType, valueType, _) => + typeContainsCalendarInterval(keyType) || typeContainsCalendarInterval(valueType) + case udt: UserDefinedType[_] => typeContainsCalendarInterval(udt.sqlType) + case _ => false + } + + /** + * Fail fast, once per partition on the driver-facing entry points, if any column type cannot be + * represented by the Arrow cache. This is the actual capability gate (supportsColumnarInput only + * chooses the input path). Without it, an unsupported type would otherwise surface as a less + * obvious failure deeper in schema conversion or statistics collection. + */ + def checkSupportedSchema(schema: Seq[Attribute]): Unit = { + schema.find(attr => !ArrowUtils.isSupportedByArrow(attr.dataType)).foreach { attr => + // Use the structured user-facing condition (UNSUPPORTED_DATATYPE) rather than an internal + // error: an unsupported column type is a user-visible limitation, and the docs promise this + // condition. It is also the same condition toArrowSchema raises, so callers see one + // condition for the capability regardless of which layer detects it first. + throw ExecutionErrors.unsupportedDataTypeError(attr.dataType) + } + } + + // scalastyle:off caselocale + def createCompressionCodec( + codecName: String, + compressionLevel: Int): CompressionCodec = { + codecName.toLowerCase match { + case "none" => NoCompressionCodec.INSTANCE + // The codec instance must be constructed directly so that compressionLevel is honored: + // CompressionCodec.Factory.createCodec(codecType) ignores the level and builds a codec at + // the default level. The level only matters on the write side; the read side looks up the + // codec by the type recorded in the IPC message. + case "zstd" => new ZstdCompressionCodec(compressionLevel) + case "lz4" => new Lz4CompressionCodec() + case other => + throw SparkException.internalError( + s"Unsupported Arrow compression codec: $other. Supported values: none, zstd, lz4") + } + } + // scalastyle:on caselocale + + def serializeBatch(batch: ArrowRecordBatch): Array[Byte] = { + val out = new ByteArrayOutputStream() + val writeChannel = new WriteChannel(Channels.newChannel(out)) + MessageSerializer.serialize(writeChannel, batch) + out.toByteArray + } + + /** + * Shut down a prefetch worker during task cleanup without leaking the root it may have produced. + * + * The prefetch worker deserializes the next batch into a fresh [[VectorSchemaRoot]] off-thread. + * If task completion runs while a result is in flight (e.g. a LIMIT consumer stops early), + * cancelling and discarding the future would drop a root that was already (or is about to be) + * produced, and the subsequent `allocator.close()` would fail with "Memory was leaked by query". + * + * This stops accepting new work, waits for the worker to finish so no root is produced after we + * stop looking, then closes any completed result. Always returns null so the caller can null out + * its future reference. Safe to call with a null executor or future. + */ + def drainAndClosePrefetch( + executor: java.util.concurrent.ExecutorService, + future: java.util.concurrent.Future[VectorSchemaRoot]): java.util.concurrent.Future[ + VectorSchemaRoot] = { + // Drain and join the worker uninterruptibly, then close any root it produced, before the + // caller closes the allocator. This runs from a task-completion listener, which can fire with + // the task thread already interrupted (e.g. a killed task). If we let awaitTermination or + // future.get observe the interrupt and bail early, the worker could still be allocating into, + // or have already returned, a root that we then neither join nor close -- and the subsequent + // allocator.close() would race the worker or fail with "Memory was leaked by query". So we + // defer every interruption -- whether present on entry or delivered while blocked (throwing + // InterruptedException clears the status, so it must be recorded here or it is lost) -- and + // restore the flag once draining is done. + var wasInterrupted = Thread.interrupted() + try { + if (executor != null) { + executor.shutdown() + var terminated = false + while (!terminated) { + try { + terminated = + executor.awaitTermination(Long.MaxValue, java.util.concurrent.TimeUnit.NANOSECONDS) + } catch { + // Record the interruption and keep waiting: we must not leave the worker running. + case _: InterruptedException => wasInterrupted = true + } + } + } + if (future != null) { + try { + // The worker has terminated, so this does not block; close the root it produced. + val root = future.get() + if (root != null) { + root.close() + } + } catch { + // The batch was never produced (cancelled/failed); nothing to close. + case _: java.util.concurrent.CancellationException => + case _: java.util.concurrent.ExecutionException => + case _: InterruptedException => wasInterrupted = true + } + } + } finally { + if (wasInterrupted) { + Thread.currentThread().interrupt() + } + } + null + } + + def createColumnStats(dataType: DataType): ColumnStats = { + dataType match { + case BooleanType => new BooleanColumnStats + case ByteType => new ByteColumnStats + case ShortType => new ShortColumnStats + case IntegerType => new IntColumnStats + case DateType => new IntColumnStats // Date is stored as Int + case LongType => new LongColumnStats + case TimestampType => new LongColumnStats // Timestamp is stored as Long + case TimestampNTZType => new LongColumnStats // TimestampNTZ is stored as Long + // Nanosecond timestamps use the TimestampNanosVal-aware collector (min/max bounds), the + // same one the default cache serializer uses for these types. + case _: TimestampNTZNanosType | _: TimestampLTZNanosType => new TimestampNanosColumnStats + case FloatType => new FloatColumnStats + case DoubleType => new DoubleColumnStats + case st: StringType => new StringColumnStats(st) + case BinaryType => new BinaryColumnStats + case dt: DecimalType => new DecimalColumnStats(dt) + case CalendarIntervalType => new IntervalColumnStats + case _: YearMonthIntervalType => new IntColumnStats // stored as Int + case _: DayTimeIntervalType => new LongColumnStats // stored as Long + case _: TimeType => new LongColumnStats // Time is stored as Long (nanoseconds) + case VariantType => new VariantColumnStats + // Geometry/Geography collect size/count without min/max bounds. Their physical value is a + // BinaryView (not Array[Byte]), so GeoColumnStats reads it via getBinaryView rather than + // BinaryColumnStats' getBinary, which would throw ClassCastException on a row that stores a + // BinaryView. They are also AtomicTypes that ColumnType (used by ObjectColumnStats) does not + // handle, so they must be matched explicitly here. + case _: GeometryType | _: GeographyType => new GeoColumnStats + // Unwrap UDTs to the same collector their underlying type would use. isSupportedByArrow + // accepts a UDT whenever its sqlType is supported (including Variant/Geometry/Geography), + // but ObjectColumnStats -> ColumnType(udt.sqlType) only unwraps one level and has no case + // for those types, so it would throw UNSUPPORTED_DATATYPE during materialization. Recursing + // here keeps the capability check and the statistics path in agreement. + case udt: UserDefinedType[_] => createColumnStats(udt.sqlType) + case _ => new ObjectColumnStats(dataType) + } + } + + def buildStatisticsFromCollectors( + collectors: Array[ColumnStats], + schema: Seq[Attribute]): InternalRow = { + val stats = collectors.flatMap { collector => + val collected = collector.collectedStatistics + // ColumnStats returns: [lowerBound, upperBound, nullCount, count, sizeInBytes] + Seq(collected(0), collected(1), collected(2), collected(3), collected(4)) + } + InternalRow.fromSeq(stats.toSeq) + } + + def collectStatistics( + root: VectorSchemaRoot, + schema: Seq[Attribute]): InternalRow = { + val rowCount = root.getRowCount + val vectors = root.getFieldVectors.asScala.toSeq + + // Collect stats for each column: lowerBound, upperBound, nullCount, rowCount, sizeInBytes + val stats = schema.zip(vectors).flatMap { case (attr, vector) => + val nullCount = (0 until rowCount).count(i => vector.isNull(i)) + val sizeInBytes = vector.getBufferSize.toLong + + val (lower, upper) = attr.dataType match { + case BooleanType => calculateMinMaxBoolean(vector, rowCount) + case ByteType => calculateMinMaxByte(vector, rowCount) + case ShortType => calculateMinMaxShort(vector, rowCount) + case IntegerType => calculateMinMaxInt(vector, rowCount) + case DateType => calculateMinMaxDate(vector, rowCount) + case LongType => calculateMinMaxLong(vector, rowCount) + case TimestampType => calculateMinMaxTimestamp(vector, rowCount) + case TimestampNTZType => calculateMinMaxTimestampNTZ(vector, rowCount) + case t: TimestampNTZNanosType => + calculateMinMaxTimestampNanos(vector, rowCount, t.precision) + case t: TimestampLTZNanosType => + calculateMinMaxTimestampNanos(vector, rowCount, t.precision) + case FloatType => calculateMinMaxFloat(vector, rowCount) + case DoubleType => calculateMinMaxDouble(vector, rowCount) + case st: StringType => calculateMinMaxString(vector, rowCount, st.collationId) + case _: DecimalType => calculateMinMaxDecimal(vector, rowCount, attr.dataType) + case _: YearMonthIntervalType => calculateMinMaxYearMonthInterval(vector, rowCount) + case _: DayTimeIntervalType => calculateMinMaxDayTimeInterval(vector, rowCount) + case _: TimeType => calculateMinMaxTime(vector, rowCount) + case _ => (null, null) // Skip for binary, complex, and other unsupported types + } + + Seq(lower, upper, nullCount, rowCount, sizeInBytes) + } + + new org.apache.spark.sql.catalyst.expressions.GenericInternalRow(stats.toArray) + } + + def calculateMinMaxBoolean( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = true + var max = false + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.BitVector].get(i) != 0 + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxByte( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Byte.MaxValue + var max = Byte.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.TinyIntVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxShort( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Short.MaxValue + var max = Short.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.SmallIntVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxInt( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Int.MaxValue + var max = Int.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.IntVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxDate( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Int.MaxValue + var max = Int.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.DateDayVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxLong( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Long.MaxValue + var max = Long.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.BigIntVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxTimestamp( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Long.MaxValue + var max = Long.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = + vector.asInstanceOf[org.apache.arrow.vector.TimeStampMicroTZVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxTimestampNTZ( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Long.MaxValue + var max = Long.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = + vector.asInstanceOf[org.apache.arrow.vector.TimeStampMicroVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxTimestampNanos( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int, + precision: Int): (Any, Any) = { + // Both TimeStampNanoVector (NTZ) and TimeStampNanoTZVector (LTZ) extend TimeStampVector and + // store epoch nanoseconds as a long; min/max over the longs matches TimestampNanosVal's + // calendar order. Convert the bounds back to TimestampNanosVal since that is the stat schema's + // bound type for these columns. + // + // Note epochNanosToTimestampNanos floor-truncates the sub-microsecond part to `precision`, + // while ArrowColumnVector's read path (decodeEpochNanos) does not truncate. Floor-truncating + // the *upper* bound down would be unsound for pruning if a value with sub-precision nanos ever + // reached this vector. That can't happen today: every write path that populates a + // precision-`p` nanos-timestamp column already truncates to `p` (see + // DateTimeUtils.truncateTimestampNanosToPrecision / makeTimestampNTZNanos), so the values + // this loop sees are always already at-or-below their column's precision and truncation here + // is a no-op. But that invariant lives entirely on the write side; if a future write path ever + // stopped truncating, this stat could silently under-report the upper bound. See + // ArrowCachedBatchSerializerSuite's "calculateMinMaxTimestampNanos upper bound is never below + // the actual max value written through the public write path" test for a regression guard. + var min = Long.MaxValue + var max = Long.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.TimeStampVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) { + (DateTimeUtils.epochNanosToTimestampNanos(min, precision), + DateTimeUtils.epochNanosToTimestampNanos(max, precision)) + } else { + (null, null) + } + } + + def calculateMinMaxFloat( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Float.MaxValue + var max = Float.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.Float4Vector].get(i) + // Skip NaN: IEEE 754 comparisons with NaN are always false, so NaN never + // updates min/max in the row-based path (FloatColumnStats.gatherValueStats). + if (!value.isNaN) { + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxDouble( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Double.MaxValue + var max = Double.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.Float8Vector].get(i) + // Skip NaN to match DoubleColumnStats.gatherValueStats. + if (!value.isNaN) { + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxString( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int, + collationId: Int = StringType.collationId): (Any, Any) = { + var min: org.apache.spark.unsafe.types.UTF8String = null + var max: org.apache.spark.unsafe.types.UTF8String = null + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val bytes = vector.asInstanceOf[org.apache.arrow.vector.VarCharVector].get(i) + val value = org.apache.spark.unsafe.types.UTF8String.fromBytes(bytes) + if (!hasValue) { + min = value.clone() + max = value.clone() + hasValue = true + } else { + if (value.semanticCompare(min, collationId) < 0) min = value.clone() + if (value.semanticCompare(max, collationId) > 0) max = value.clone() + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxDecimal( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int, + dataType: org.apache.spark.sql.types.DataType): (Any, Any) = { + val decimalType = dataType.asInstanceOf[DecimalType] + var min: org.apache.spark.sql.types.Decimal = null + var max: org.apache.spark.sql.types.Decimal = null + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val bigDecimal = vector.asInstanceOf[ + org.apache.arrow.vector.DecimalVector].getObject(i) + val value = org.apache.spark.sql.types.Decimal( + bigDecimal, decimalType.precision, decimalType.scale) + + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value.compareTo(min) < 0) min = value + if (value.compareTo(max) > 0) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxYearMonthInterval( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Int.MaxValue + var max = Int.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.IntervalYearVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxDayTimeInterval( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Long.MaxValue + var max = Long.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = org.apache.arrow.vector.DurationVector.get( + vector.asInstanceOf[org.apache.arrow.vector.DurationVector].getDataBuffer, i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } + + def calculateMinMaxTime( + vector: org.apache.arrow.vector.FieldVector, + rowCount: Int): (Any, Any) = { + var min = Long.MaxValue + var max = Long.MinValue + var hasValue = false + + (0 until rowCount).foreach { i => + if (!vector.isNull(i)) { + val value = vector.asInstanceOf[org.apache.arrow.vector.TimeNanoVector].get(i) + if (!hasValue) { + min = value + max = value + hasValue = true + } else { + if (value < min) min = value + if (value > max) max = value + } + } + } + + if (hasValue) (min, max) else (null, null) + } +} + +/** + * Iterator that converts InternalRow to ArrowCachedBatch. + */ +private class InternalRowToArrowCachedBatchIterator( + rowIter: Iterator[InternalRow], + schema: Seq[Attribute], + sparkSchema: StructType, + maxRecordsPerBatch: Long, + maxBytesPerBatch: Long, + timeZoneId: String, + compressionCodecName: String, + compressionLevel: Int) extends Iterator[ArrowCachedBatch] { + + private val compressionCodec = ArrowCachedBatchSerializer.createCompressionCodec( + compressionCodecName, + compressionLevel) + + private val allocator = ArrowUtils.rootAllocator.newChildAllocator( + s"InternalRowToArrowCachedBatchIterator-${TaskContext.get().taskAttemptId()}", + 0, + Long.MaxValue) + + private val arrowSchema = ArrowUtils.toArrowSchema(sparkSchema, timeZoneId, false, false) + private val root = VectorSchemaRoot.create(arrowSchema, allocator) + private val arrowWriter = ArrowWriter.create(root) + private val unloader = new VectorUnloader(root, true, compressionCodec, true) + + // Create statistics collectors for each column + private val statsCollectors: Array[ColumnStats] = schema.map { attr => + ArrowCachedBatchSerializer.createColumnStats(attr.dataType) + }.toArray + + // Computed once: only CalendarInterval columns can overflow when written to Arrow nanoseconds. + private val hasCalendarInterval = ArrowCachedBatchSerializer.hasCalendarInterval(schema) + + // Register cleanup + Option(TaskContext.get()).foreach { tc => + tc.addTaskCompletionListener[Unit] { _ => + close() + } + } + + override def hasNext: Boolean = rowIter.hasNext || { + close() + false + } + + override def next(): ArrowCachedBatch = { + var rowCount = 0 + + // Reset statistics collectors for new batch + var idx = 0 + while (idx < statsCollectors.length) { + statsCollectors(idx) = ArrowCachedBatchSerializer.createColumnStats(schema(idx).dataType) + idx += 1 + } + + Utils.tryWithSafeFinally { + // Write rows to Arrow vectors and collect statistics incrementally, stopping when either the + // record-count or byte limit is reached (whichever is hit first), so wide rows cannot form + // multi-gigabyte batches that exhaust memory or overflow Arrow's 32-bit variable-width + // offsets. A nonpositive limit means that limit is unlimited; the `<= 0` guards also keep the + // loop from emitting empty batches forever. At least one row is always written so a single + // oversized row still makes progress. The byte limit is measured from the actual bytes + // already written to the Arrow vectors (arrowWriter.sizeInBytes), which is accurate for every + // row type -- a row-size estimate would undercount large values in a GenericInternalRow (e.g. + // a multi-megabyte string) and let the batch grow past the limit. + def recordLimitReached: Boolean = maxRecordsPerBatch > 0 && rowCount >= maxRecordsPerBatch + def byteLimitReached: Boolean = + maxBytesPerBatch > 0 && arrowWriter.sizeInBytes() >= maxBytesPerBatch + ArrowCachedBatchSerializer.withIntervalOverflowTranslation(hasCalendarInterval) { + while (rowIter.hasNext && (rowCount == 0 || (!recordLimitReached && !byteLimitReached))) { + val row = rowIter.next() + arrowWriter.write(row) + + // Collect statistics for this row + var i = 0 + while (i < statsCollectors.length) { + statsCollectors(i).gatherStats(row, i) + i += 1 + } + + rowCount += 1 + } + arrowWriter.finish() + } + + // Get the Arrow RecordBatch with compression + val recordBatch = unloader.getRecordBatch() + + Utils.tryWithSafeFinally { + // Serialize to Arrow IPC format + val arrowData = ArrowCachedBatchSerializer.serializeBatch(recordBatch) + + // Build statistics InternalRow from collected stats + val stats = ArrowCachedBatchSerializer.buildStatisticsFromCollectors( + statsCollectors, schema) + + ArrowCachedBatch(rowCount, arrowData, stats) + } { + recordBatch.close() + } + } { + arrowWriter.reset() + } + } + + private def close(): Unit = { + root.close() + allocator.close() + } +} + +/** + * Iterator that converts ColumnarBatch to ArrowCachedBatch. + */ +private class ColumnarBatchToArrowCachedBatchIterator( + batchIter: Iterator[ColumnarBatch], + schema: Seq[Attribute], + sparkSchema: StructType, + timeZoneId: String, + compressionCodecName: String, + compressionLevel: Int) extends Iterator[ArrowCachedBatch] { + + private val compressionCodec = ArrowCachedBatchSerializer.createCompressionCodec( + compressionCodecName, + compressionLevel) + + private val allocator = ArrowUtils.rootAllocator.newChildAllocator( + s"ColumnarBatchToArrowCachedBatchIterator-${TaskContext.get().taskAttemptId()}", + 0, + Long.MaxValue) + + private val arrowSchema = ArrowUtils.toArrowSchema(sparkSchema, timeZoneId, false, false) + + // Computed once: only CalendarInterval columns can overflow when written to Arrow nanoseconds. + private val hasCalendarInterval = ArrowCachedBatchSerializer.hasCalendarInterval(schema) + + // Register cleanup + Option(TaskContext.get()).foreach { tc => + tc.addTaskCompletionListener[Unit] { _ => + allocator.close() + } + } + + override def hasNext: Boolean = batchIter.hasNext + + override def next(): ArrowCachedBatch = { + val batch = batchIter.next() + // Release the consumed input batch once converted. This iterator replaces the normal + // ColumnarToRow consumer (which calls closeIfFreeable() after each batch), so without this + // an Arrow-backed source's fresh off-heap vectors would stay live for every cached batch and + // grow executor memory until OOM. Both conversion branches finish synchronously and copy the + // data out (VectorUnloader / row conversion), so the input is safe to free here on success or + // failure; closeIfFreeable() is a no-op for reusable writable/constant vectors. + // One input ColumnarBatch maps to one cached batch: the upstream batch's row count is already + // bounded by the source's batch-size config (e.g. spark.sql.parquet.columnarReaderBatchSize), + // so no further record/byte splitting is needed here. + Utils.tryWithSafeFinally { + val rowCount = batch.numRows() + + // Check if batch is already Arrow-based for zero-copy path. The zero-copy path reuses the + // input vectors but serializes them under a schema built with largeVarTypes=false, and the + // read path reconstructs that same non-large schema. Large var-width vectors use 64-bit + // offsets, so reading them back under a 32-bit-offset schema would silently corrupt data. + // Fall back to the row-based conversion (which always produces standard var-width vectors) + // whenever any input vector is, or nests, a large var-width vector. + val vectors = (0 until batch.numCols()).map(batch.column) + val zeroCopyEligible = vectors.forall { + case acv: ArrowColumnVector => + !ColumnarBatchToArrowCachedBatchIterator.containsLargeVarType(acv.getValueVector) + case _ => false + } + if (zeroCopyEligible) { + // Fast path: zero-copy extraction of Arrow RecordBatch + convertArrowBatchZeroCopy(batch, rowCount, schema, vectors) + } else { + // Slow path: convert to Arrow via rows + convertToArrowBatch(batch, rowCount, schema) + } + } { + batch.closeIfFreeable() + } + } + + private def convertArrowBatchZeroCopy( + batch: ColumnarBatch, + rowCount: Int, + schema: Seq[Attribute], + vectors: Seq[ColumnVector]): ArrowCachedBatch = { + // Zero-copy path: extract Arrow vectors directly from ArrowColumnVector. + // This path deliberately does not run withIntervalOverflowTranslation (CalendarInterval) or + // any equivalent nanos-timestamp overflow guard: it reuses vectors that came from an + // upstream Arrow source (ArrowColumnVector.getValueVector), and those vectors already store + // int64 epoch-nanos/nanos-since-epoch values that were written by an ArrowWriter (or + // equivalent) that itself enforced the guard. There is no conversion here that can newly + // overflow. If a future caller could reach this path with vectors that were populated + // without going through the guarded write path, this comment's assumption would need + // revisiting. + val arrowVectors = vectors.map( + _.asInstanceOf[ArrowColumnVector].getValueVector.asInstanceOf[ + org.apache.arrow.vector.FieldVector]) + + // Create a VectorSchemaRoot from the existing vectors + val root = new VectorSchemaRoot(arrowSchema, arrowVectors.asJava, rowCount) + + Utils.tryWithSafeFinally { + // Use VectorUnloader to create compressed RecordBatch + val unloader = new VectorUnloader(root, true, compressionCodec, true) + val recordBatch = unloader.getRecordBatch() + + Utils.tryWithSafeFinally { + val arrowData = ArrowCachedBatchSerializer.serializeBatch(recordBatch) + val stats = ArrowCachedBatchSerializer.collectStatistics(root, schema) + ArrowCachedBatch(rowCount, arrowData, stats) + } { + recordBatch.close() + } + } { + // Note: We don't close the root here because we don't own the vectors -- they are owned by + // the input ColumnarBatch, whose buffers the caller frees via batch.closeIfFreeable() after + // this method returns. That is not a use-after-free: serializeBatch/collectStatistics above + // already copy everything this method returns (arrowData is a materialized Array[Byte], + // stats are plain values) out of the vectors before this method returns, so nothing here + // still references the input's buffers once the caller frees them. + } + } + + private def convertToArrowBatch( + batch: ColumnarBatch, + rowCount: Int, + schema: Seq[Attribute]): ArrowCachedBatch = { + // Convert columnar batch to rows, then to Arrow + val root = VectorSchemaRoot.create(arrowSchema, allocator) + val arrowWriter = ArrowWriter.create(root) + val unloader = new VectorUnloader(root, true, compressionCodec, true) + + Utils.tryWithSafeFinally { + ArrowCachedBatchSerializer.withIntervalOverflowTranslation(hasCalendarInterval) { + val rowIterator = batch.rowIterator().asScala + while (rowIterator.hasNext) { + arrowWriter.write(rowIterator.next()) + } + arrowWriter.finish() + } + + val recordBatch = unloader.getRecordBatch() + Utils.tryWithSafeFinally { + val arrowData = ArrowCachedBatchSerializer.serializeBatch(recordBatch) + // Derive statistics from the built Arrow vectors rather than the input rows. The input + // rows here are ColumnarArray/ColumnarMap/ColumnarRow views (this is the non-Arrow + // ColumnarBatch path, e.g. vectorized nested Parquet/ORC), which do not expose a byte + // size; collecting row-by-row would record zero bytes for every complex value and make a + // complex-only relation report sizeInBytes=0, wrongly eligible for broadcast. Reading + // vector.getBufferSize off the finished root accounts for the actual payload, matching the + // zero-copy path. + val stats = ArrowCachedBatchSerializer.collectStatistics(root, schema) + ArrowCachedBatch(rowCount, arrowData, stats) + } { + recordBatch.close() + } + } { + arrowWriter.reset() + root.close() + } + } +} + +private object ColumnarBatchToArrowCachedBatchIterator { + import org.apache.arrow.vector.{FieldVector, LargeVarBinaryVector, LargeVarCharVector} + + /** + * Whether the vector is, or nests, a large var-width vector (64-bit offsets). These are not + * eligible for the zero-copy path because that path serializes and reloads under a schema built + * with largeVarTypes=false; reinterpreting 64-bit offset buffers as 32-bit would corrupt data. + */ + def containsLargeVarType(vector: org.apache.arrow.vector.ValueVector): Boolean = vector match { + case _: LargeVarCharVector | _: LargeVarBinaryVector => true + case fv: FieldVector => + fv.getChildrenFromFields.asScala.exists(containsLargeVarType) + case _ => false + } +} + +/** + * Iterator that converts ArrowCachedBatch to ColumnarBatch. + */ +private class ArrowCachedBatchToColumnarBatchIterator( + batchIter: Iterator[CachedBatch], + cacheSchema: StructType, + selectedSchema: StructType, + columnIndices: Array[Int], + timeZoneId: String, + prefetchEnabled: Boolean = false) extends Iterator[ColumnarBatch] { + + import java.util.concurrent.{Callable, ExecutionException, Executors, ExecutorService, Future} + + private val allocator = ArrowUtils.rootAllocator.newChildAllocator( + s"ArrowCachedBatchToColumnarBatchIterator-${TaskContext.get().taskAttemptId()}", + 0, + Long.MaxValue) + + private val arrowSchema = ArrowUtils.toArrowSchema(cacheSchema, timeZoneId, false, false) + + // Track only the previous root to close it when next batch is produced + private var previousRoot: VectorSchemaRoot = null + + // Prefetch support: deserialize the next batch into its own root in a background thread while + // the current batch is being consumed. Only the deserialization (IPC read + decompression + + // loading into a fresh root) happens off-thread; closing the previous root stays on the + // consumer thread in next(), so the vectors backing a returned ColumnarBatch are never released + // while the consumer may still read them. + private val prefetchExecutor: ExecutorService = if (prefetchEnabled) { + Executors.newSingleThreadExecutor(r => { + val t = new Thread(r, "arrow-cache-prefetch") + t.setDaemon(true) + t + }) + } else { + null + } + private var prefetchFuture: Future[VectorSchemaRoot] = _ + + // Register cleanup - close remaining root and allocator when task completes + Option(TaskContext.get()).foreach { tc => + tc.addTaskCompletionListener[Unit] { _ => + // Stop the worker and close any root it already produced before closing the allocator. + // A short-circuiting consumer (e.g. LIMIT) can trigger task completion while a prefetched + // root is in flight; simply cancelling the future would drop that root and allocator.close() + // would then fail with "Memory was leaked by query". + prefetchFuture = ArrowCachedBatchSerializer.drainAndClosePrefetch( + prefetchExecutor, prefetchFuture) + if (previousRoot != null) { + previousRoot.close() + previousRoot = null + } + allocator.close() + } + } + + override def hasNext: Boolean = prefetchFuture != null || batchIter.hasNext + + override def next(): ColumnarBatch = { + // Close the previous root since the consumer has moved on from the batch it backed. + if (previousRoot != null) { + previousRoot.close() + previousRoot = null + } + + val root = if (prefetchFuture != null) { + val r = try { + prefetchFuture.get() + } catch { + case e: ExecutionException => throw e.getCause + } + prefetchFuture = null + r + } else { + deserializeToRoot(batchIter.next().asInstanceOf[ArrowCachedBatch]) + } + + previousRoot = root + + // Wrap vectors in ArrowColumnVector and project to selected columns. + val allColumns = root.getFieldVectors.asScala.map { vector => + new ArrowColumnVector(vector) + }.toArray[ColumnVector] + val selectedColumns = columnIndices.map(allColumns(_)) + val batch = new ColumnarBatch(selectedColumns, root.getRowCount) + + // Start prefetching the next batch while this one is being consumed. + submitPrefetch() + + batch + } + + /** Deserialize a cached batch into its own freshly-created root. Does not touch other roots. */ + private def deserializeToRoot(cachedBatch: ArrowCachedBatch): VectorSchemaRoot = { + val in = new ByteArrayInputStream(cachedBatch.arrowData) + val readChannel = new ReadChannel(Channels.newChannel(in)) + val recordBatch = MessageSerializer.deserializeRecordBatch(readChannel, allocator) + Utils.tryWithSafeFinally { + val root = VectorSchemaRoot.create(arrowSchema, allocator) + // VectorLoader.load fills vectors incrementally, so a failure (malformed data, decompression + // error, OOM) can occur after earlier vectors have allocated buffers. Close the partially + // loaded root on failure, otherwise it becomes unreachable and the later allocator.close() + // fails with a leak error that masks the original exception. + try { + val loader = new VectorLoader(root) + loader.load(recordBatch) + root + } catch { + case t: Throwable => + root.close() + throw t + } + } { + recordBatch.close() + } + } + + /** Submit deserialization of the next batch to the background thread, if prefetch is enabled. */ + private def submitPrefetch(): Unit = { + if (prefetchEnabled && batchIter.hasNext) { + val nextCachedBatch = batchIter.next().asInstanceOf[ArrowCachedBatch] + prefetchFuture = prefetchExecutor.submit(new Callable[VectorSchemaRoot] { + override def call(): VectorSchemaRoot = deserializeToRoot(nextCachedBatch) + }) + } + } +} + +/** + * A typed column reader that reads from an Arrow FieldVector and writes directly + * to an UnsafeRowWriter, avoiding per-row pattern matching overhead. + */ +private abstract class ArrowColumnReader { + def vector: org.apache.arrow.vector.FieldVector + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit + def setVector(v: org.apache.arrow.vector.FieldVector): Unit +} + +private object ArrowColumnReader { + import org.apache.arrow.vector._ + + def create(dataType: DataType): ArrowColumnReader = dataType match { + case BooleanType => new ArrowColumnReader { + private var _vector: BitVector = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = _vector = v.asInstanceOf[BitVector] + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = + writer.write(ordinal, _vector.get(rowIndex) != 0) + } + case ByteType => new ArrowColumnReader { + private var _vector: TinyIntVector = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = _vector = v.asInstanceOf[TinyIntVector] + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = + writer.write(ordinal, _vector.get(rowIndex)) + } + case ShortType => new ArrowColumnReader { + private var _vector: SmallIntVector = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = _vector = v.asInstanceOf[SmallIntVector] + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = + writer.write(ordinal, _vector.get(rowIndex)) + } + case IntegerType | DateType | _: YearMonthIntervalType => new ArrowColumnReader { + private var _vector: FieldVector = _ + // Pre-bind accessor at setVector time to avoid per-row pattern match + private var _accessor: Int => Int = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = { + _vector = v + _accessor = v match { + case iv: IntVector => iv.get + case dv: DateDayVector => dv.get + case iv: org.apache.arrow.vector.IntervalYearVector => iv.get + } + } + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = + writer.write(ordinal, _accessor(rowIndex)) + } + case LongType | TimestampType | TimestampNTZType | _: DayTimeIntervalType | _: TimeType => + new ArrowColumnReader { + private var _vector: FieldVector = _ + private var _accessor: Int => Long = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = { + _vector = v + _accessor = v match { + case bv: BigIntVector => bv.get(_) + case tv: TimeStampMicroTZVector => tv.get(_) + case tv: TimeStampMicroVector => tv.get(_) + case dv: org.apache.arrow.vector.DurationVector => + i => org.apache.arrow.vector.DurationVector.get(dv.getDataBuffer, i) + case tv: org.apache.arrow.vector.TimeNanoVector => tv.get(_) + } + } + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = + writer.write(ordinal, _accessor(rowIndex)) + } + case FloatType => new ArrowColumnReader { + private var _vector: Float4Vector = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = _vector = v.asInstanceOf[Float4Vector] + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = + writer.write(ordinal, _vector.get(rowIndex)) + } + case DoubleType => new ArrowColumnReader { + private var _vector: Float8Vector = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = _vector = v.asInstanceOf[Float8Vector] + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = + writer.write(ordinal, _vector.get(rowIndex)) + } + case _: StringType => new ArrowColumnReader { + private var _vector: VarCharVector = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = _vector = v.asInstanceOf[VarCharVector] + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = { + val bytes = _vector.get(rowIndex) + writer.write(ordinal, UTF8String.fromBytes(bytes)) + } + } + case BinaryType => new ArrowColumnReader { + private var _vector: VarBinaryVector = _ + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = _vector = v.asInstanceOf[VarBinaryVector] + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = + writer.write(ordinal, _vector.get(rowIndex)) + } + case dt: DecimalType if dt.precision <= Decimal.MAX_LONG_DIGITS => + // Fast path for compact decimals (precision <= 18): + // Read the unscaled long directly from the Arrow buffer, zero allocation. + // Arrow stores Decimal as 128-bit little-endian integer in 16 bytes. + // For compact decimals, the value fits in the lower 8 bytes. + new ArrowColumnReader { + private var _vector: DecimalVector = _ + private var _dataBuffer: org.apache.arrow.memory.ArrowBuf = _ + private val typeWidth = DecimalVector.TYPE_WIDTH // 16 bytes + def vector: FieldVector = _vector + def setVector(v: FieldVector): Unit = { + _vector = v.asInstanceOf[DecimalVector] + _dataBuffer = _vector.getDataBuffer + } + def read(rowIndex: Int, ordinal: Int, writer: UnsafeRowWriter): Unit = { + val startIndex = rowIndex.toLong * typeWidth + val unscaledLong = _dataBuffer.getLong(startIndex) Review Comment: [P2] Please make the compact-decimal fast path endian-safe. Arrow Java stores Decimal128 in native byte order: on big-endian JVMs the first eight bytes are only the sign-extension word, while the unscaled `Long` is at `startIndex + 8`. This unconditional read therefore turns positive `Decimal(p <= 18)` values into zero and negative values into an unscaled `-1` whenever the cache uses the row reader (for example, with vectorized cache reads disabled). A `DECIMAL(10,2)` value of `123.45` consequently round-trips as `0.00` on s390x. The previous `DecimalVector.getObject` path and Spark's default cache are endian-safe. Please use the vector accessor or choose the low-word offset based on native byte order, and cover the big-endian layout in a regression test. -- 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]
