This is an automated email from the ASF dual-hosted git repository.
MaxGekk pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 88fa2af227f2 [SPARK-55444][SQL] Introduce and Route TimeType to
Parquet vectorized read through the Types Framework
88fa2af227f2 is described below
commit 88fa2af227f2f85f2cd7bfd8d61d76c1198aca78
Author: Stevo Mitric <[email protected]>
AuthorDate: Wed Jul 1 21:53:34 2026 +0200
[SPARK-55444][SQL] Introduce and Route TimeType to Parquet vectorized read
through the Types Framework
### What changes were proposed in this pull request?
Follow-up to the Types Framework Phase 3a Parquet work
([SPARK-55444](https://issues.apache.org/jira/browse/SPARK-55444)). It moves
`TimeType`'s **vectorized** Parquet decoder into the framework, so the
framework now owns all of `TimeType`'s Parquet read/write paths (schema, write,
row-based read, and now vectorized read).
- Adds a framework dispatch hook at the top of
`ParquetVectorUpdaterFactory.getUpdater` (before the `switch (typeName)`):
`ParquetTypeOps.getVectorUpdaterOrNull(sparkType, descriptor)`. A
framework-managed type returning a `Some` updater short-circuits the factory's
built-in cases; everything else returns `null` and falls through unchanged.
- Adds the trait extension point
`ParquetTypeOps.getVectorUpdater(descriptor): Option[ParquetVectorUpdater]`
(default `None`), symmetric with the existing `newConverter` row hook and
dispatched through the same `apply(dt)` registration.
- `TimeTypeParquetOps` owns the updater: a Scala `TimeVectorUpdater`
replacing the removed factory `TimeUpdater` (INT64 micros/nanos-of-day →
nanos-of-day, truncated to the requested precision).
- Unifies the read guard: `getVectorUpdater` validates the descriptor via a
newly-extracted `isCompatibleParquetType` — the same predicate the row path's
`requireCompatibleParquetType` now delegates to. Incompatible encodings (INT32
TIME(MILLIS), raw INT64, INT64 TIMESTAMP) return `None` and fall through to the
factory's clean `SchemaColumnConvertNotSupportedException` instead of silently
mis-reading. Both readers now accept/reject the identical encoding set.
- Truncation reuses the shared, table-backed
`DateTimeUtils.truncateTimeToPrecision` (the same call the row path uses) — no
duplicate factor table.
- Renames the stale `ParquetVectorUpdaterBenchmark` case label to
`TimeVectorUpdater (TimeType)`.
### Why are the changes needed?
The vectorized Parquet **decoder** was the last `TimeType` updater living
outside the framework: `ParquetVectorUpdaterFactory.getUpdater` had `instanceof
TimeType` arms in its INT64 case, so `ParquetTypeOps.isBatchReadSupported =
true` for `TimeType` was only safe because of out-of-framework factory code.
Moving it in makes that flag honest and lets future framework types supply
their own batch updater with no factory changes.
(`VectorizedColumnReader.isLazyDecodingSupported`'s TIME c [...]
### Does this PR introduce _any_ user-facing change?
No. `TimeVectorUpdater` performs the same conversion + precision truncation
as the removed factory `TimeUpdater`, and `isCompatibleParquetType` preserves
the existing accept/reject behavior on both readers.
### How was this patch tested?
- `TimeTypeParquetOpsSuite`: dispatch tests plus a vectorized reject test
(INT32 TIME(MILLIS) / raw INT64 / INT64 TIMESTAMP read as `TimeType` →
`getVectorUpdater` returns `None`).
- `ParquetIOSuite` TIME tests under `withAllParquetReaders` (vectorized +
row, dict on/off, MICROS/NANOS, `isAdjustedToUTC`, read-side precision
truncation) exercise the relocated decode end-to-end.
- `ParquetVectorizedSuite` passes unchanged.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
Closes #56661 from stevomitric/stevomitric/parquet-tf-vectorized-read.
Authored-by: Stevo Mitric <[email protected]>
Signed-off-by: Max Gekk <[email protected]>
---
...ParquetVectorUpdaterBenchmark-jdk21-results.txt | 1 +
...ParquetVectorUpdaterBenchmark-jdk25-results.txt | 1 +
.../ParquetVectorUpdaterBenchmark-results.txt | 1 +
.../parquet/ParquetVectorUpdaterFactory.java | 89 ++----------------
.../parquet/types/ops/ParquetTypeOps.scala | 69 +++++++++-----
.../parquet/types/ops/TimeTypeParquetOps.scala | 104 +++++++++++++++++----
.../datasources/parquet/ParquetIOSuite.scala | 73 +++++++++++++++
.../parquet/ParquetVectorUpdaterBenchmark.scala | 4 +-
.../parquet/ParquetVectorUpdaterSuite.scala | 28 ++++++
.../types/ops/TimeTypeParquetOpsSuite.scala | 42 ++++++++-
10 files changed, 285 insertions(+), 127 deletions(-)
diff --git
a/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-jdk21-results.txt
b/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-jdk21-results.txt
index 79f08a7c511c..a8967151ec31 100644
--- a/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-jdk21-results.txt
+++ b/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-jdk21-results.txt
@@ -28,6 +28,7 @@ IntegerToLongUpdater 0
0
IntegerToDoubleUpdater 0 0
0 6120.9 0.2 1.0X
FloatToDoubleUpdater 0 0
0 2527.0 0.4 0.4X
DateToTimestampNTZUpdater 1 1
0 935.1 1.1 0.2X
+TimeVectorUpdater (TimeType) 1 1
0 1228.5 0.8 0.2X
DowncastLongUpdater (INT64 -> Decimal(9,2)) 0 0
0 5823.3 0.2 0.9X
diff --git
a/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-jdk25-results.txt
b/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-jdk25-results.txt
index 985b53c7d9fd..4303c196bfda 100644
--- a/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-jdk25-results.txt
+++ b/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-jdk25-results.txt
@@ -28,6 +28,7 @@ IntegerToLongUpdater 0
0
IntegerToDoubleUpdater 0 0
0 6568.8 0.2 1.3X
FloatToDoubleUpdater 0 0
0 3189.9 0.3 0.6X
DateToTimestampNTZUpdater 1 1
0 884.2 1.1 0.2X
+TimeVectorUpdater (TimeType) 1 1
0 1115.4 0.9 0.2X
DowncastLongUpdater (INT64 -> Decimal(9,2)) 0 0
0 5089.5 0.2 1.0X
diff --git a/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-results.txt
b/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-results.txt
index 95c02e4dd9e9..7930d1b63bcc 100644
--- a/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-results.txt
+++ b/sql/core/benchmarks/ParquetVectorUpdaterBenchmark-results.txt
@@ -28,6 +28,7 @@ IntegerToLongUpdater 1
1
IntegerToDoubleUpdater 1 1
0 1556.5 0.6 1.2X
FloatToDoubleUpdater 1 1
0 1418.2 0.7 1.1X
DateToTimestampNTZUpdater 2 2
0 605.1 1.7 0.5X
+TimeVectorUpdater (TimeType) 1 1
0 942.2 1.1 0.7X
DowncastLongUpdater (INT64 -> Decimal(9,2)) 1 1
0 1287.2 0.8 1.0X
diff --git
a/sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterFactory.java
b/sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterFactory.java
index 544a60a21222..b15777eacc52 100644
---
a/sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterFactory.java
+++
b/sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterFactory.java
@@ -34,6 +34,7 @@ import org.apache.spark.sql.catalyst.util.DateTimeUtils;
import org.apache.spark.sql.catalyst.util.RebaseDateTime;
import org.apache.spark.sql.execution.datasources.DataSourceUtils;
import
org.apache.spark.sql.execution.datasources.SchemaColumnConvertNotSupportedException;
+import
org.apache.spark.sql.execution.datasources.parquet.types.ops.ParquetTypeOps$;
import org.apache.spark.sql.execution.vectorized.WritableColumnVector;
import org.apache.spark.sql.types.*;
@@ -78,6 +79,13 @@ public class ParquetVectorUpdaterFactory {
return new NullTypeUpdater();
}
+ // Types Framework: a framework-managed type provides its own vectorized
updater.
+ ParquetVectorUpdater frameworkUpdater =
+ ParquetTypeOps$.MODULE$.getVectorUpdaterOrNull(sparkType, descriptor);
+ if (frameworkUpdater != null) {
+ return frameworkUpdater;
+ }
+
switch (typeName) {
case BOOLEAN -> {
if (sparkType == DataTypes.BooleanType) {
@@ -165,17 +173,6 @@ public class ParquetVectorUpdaterFactory {
return new LongUpdater();
} else if (canReadAsDecimal(descriptor, sparkType)) {
return new LongToDecimalUpdater(descriptor, (DecimalType) sparkType);
- } else if (sparkType instanceof TimeType &&
- isTimeTypeMatched(LogicalTypeAnnotation.TimeUnit.NANOS)) {
- // TIME(NANOS) is stored as nanoseconds since midnight, matching the
internal
- // representation, so no unit conversion is needed; the decoded
value is truncated to
- // the requested precision (consistent with the row-based
ParquetRowConverter path).
- return new TimeUpdater(((TimeType) sparkType).precision(), /*
fileStoresNanos = */ true);
- } else if (sparkType instanceof TimeType &&
- isTimeTypeMatched(LogicalTypeAnnotation.TimeUnit.MICROS)) {
- // TIME(MICROS) is converted to nanoseconds, then truncated to the
requested precision
- // (consistent with the row-based ParquetRowConverter path).
- return new TimeUpdater(((TimeType) sparkType).precision(), /*
fileStoresNanos = */ false);
}
}
case FLOAT -> {
@@ -922,76 +919,6 @@ public class ParquetVectorUpdaterFactory {
}
}
- // Reads an INT64 TIME column into the internal nanoseconds-since-midnight
representation and
- // truncates it to the requested TimeType precision. `fileStoresNanos`
selects the on-disk unit:
- // TIME(NANOS) stores nanos directly (identity), TIME(MICROS) stores micros
(converted to nanos).
- // Mirrors the row-based ParquetRowConverter path so the vectorized and
non-vectorized readers
- // agree on the decoded value, including when the requested precision is
lower than the on-disk
- // value's precision.
- // 10^k for k in [0, 9] (TimeType.NANOS_PRECISION), indexed by the
truncation scale
- // (NANOS_PRECISION - p), used to truncate a nanosecond TIME value to the
requested
- // fractional-second precision. Length - 1 equals TimeType.NANOS_PRECISION.
- private static final long[] TIME_TRUNCATION_FACTORS = {
- 1L, 10L, 100L, 1_000L, 10_000L, 100_000L,
- 1_000_000L, 10_000_000L, 100_000_000L, 1_000_000_000L
- };
-
- private static class TimeUpdater implements ParquetVectorUpdater {
- // The truncation step for the requested precision. The precision is
constant per column, so the
- // factor is looked up once here rather than recomputed per value via the
math.pow in
- // DateTimeUtils.truncateTimeToPrecision (this is the vectorized hot loop).
- private final long truncationFactor;
- private final boolean fileStoresNanos;
-
- TimeUpdater(int precision, boolean fileStoresNanos) {
- this.fileStoresNanos = fileStoresNanos;
- // scale = NANOS_PRECISION - precision; NANOS_PRECISION ==
factors.length - 1.
- int scale = TIME_TRUNCATION_FACTORS.length - 1 - precision;
- this.truncationFactor = TIME_TRUNCATION_FACTORS[scale];
- }
-
- private long toTruncatedNanos(long value) {
- long nanos = fileStoresNanos ? value :
DateTimeUtils.microsToNanos(value);
- // Equivalent to DateTimeUtils.truncateTimeToPrecision with the factor
hoisted.
- return (nanos / truncationFactor) * truncationFactor;
- }
-
- @Override
- public void readValues(
- int total,
- int offset,
- WritableColumnVector values,
- VectorizedValuesReader valuesReader) {
- valuesReader.readLongs(total, values, offset);
- for (int i = 0; i < total; i++) {
- values.putLong(offset + i, toTruncatedNanos(values.getLong(offset +
i)));
- }
- }
-
- @Override
- public void skipValues(int total, VectorizedValuesReader valuesReader) {
- valuesReader.skipLongs(total);
- }
-
- @Override
- public void readValue(
- int offset,
- WritableColumnVector values,
- VectorizedValuesReader valuesReader) {
- values.putLong(offset, toTruncatedNanos(valuesReader.readLong()));
- }
-
- @Override
- public void decodeSingleDictionaryId(
- int offset,
- WritableColumnVector values,
- WritableColumnVector dictionaryIds,
- Dictionary dictionary) {
- long value = dictionary.decodeToLong(dictionaryIds.getDictId(offset));
- values.putLong(offset, toTruncatedNanos(value));
- }
- }
-
private static class FloatUpdater implements ParquetVectorUpdater {
@Override
public void readValues(
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
index 598ec75fb45f..4184efa82f84 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/ParquetTypeOps.scala
@@ -19,13 +19,14 @@ package
org.apache.spark.sql.execution.datasources.parquet.types.ops
import java.time.ZoneId
+import org.apache.parquet.column.ColumnDescriptor
import org.apache.parquet.io.api.{Converter, RecordConsumer}
import org.apache.parquet.schema.Type
import org.apache.parquet.schema.Type.Repetition
import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
import org.apache.spark.sql.catalyst.util.RebaseDateTime.RebaseSpec
-import
org.apache.spark.sql.execution.datasources.parquet.{HasParentContainerUpdater,
ParentContainerUpdater, ParquetToSparkSchemaConverter}
+import
org.apache.spark.sql.execution.datasources.parquet.{HasParentContainerUpdater,
ParentContainerUpdater, ParquetToSparkSchemaConverter, ParquetVectorUpdater}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.{DataType, StructType,
TimestampLTZNanosType, TimestampNTZNanosType, TimeType}
@@ -40,13 +41,12 @@ import org.apache.spark.sql.types.{DataType, StructType,
TimestampLTZNanosType,
* - Schema conversion: Spark DataType -> Parquet schema type (write path)
* - Value write: writing values to Parquet RecordConsumer
* - Row-based read: creating Parquet converters for reading into InternalRow
- * - Type gates: declaring Parquet support (supportDataType) and the
vectorized-read
- * capability flag (isBatchReadSupported)
+ * - Vectorized read: the batch capability flag (isBatchReadSupported) and
the batch
+ * updater (getVectorUpdater)
+ * - Type gates: declaring Parquet support (supportDataType)
* - Schema clipping: declaring internal struct schema for column pruning
*
- * NOT yet on the trait (deferred to follow-ups): vectorized-read batch
updaters and
- * filter-pushdown predicates. Only the isBatchReadSupported capability gate
exists today;
- * the actual vectorized updater and filter predicate hooks are not
implemented here.
+ * NOT yet on the trait (deferred to follow-ups): filter-pushdown predicates.
*
* DISPATCH PATTERN: Framework FIRST at all integration sites. Each Parquet
infrastructure
* method wraps itself with:
@@ -167,23 +167,6 @@ private[parquet] trait ParquetTypeOps extends Serializable
{
*/
def supportDataType: Boolean = true
- /**
- * Whether vectorized (batch) reading is supported for this type.
- * Used by ParquetUtils.isBatchReadSupported. Default is false - types must
opt in
- * by overriding to true. When false, Spark uses the row-based read path
(newConverter)
- * which is always available.
- *
- * PRECONDITION: there is no framework vectorized-read hook yet, so
returning true is only
- * safe for a type the legacy Java vectorized path
(ParquetVectorUpdaterFactory /
- * VectorizedColumnReader) already handles. A new type that returns true
without that
- * legacy support would route into a factory that does not recognize it.
TimeType is safe
- * here precisely because the legacy path handles it; until the vectorized
integration
- * (follow-up) lands, other types should leave this false.
- *
- * @param sqlConf the active SQL configuration
- */
- def isBatchReadSupported(sqlConf: SQLConf): Boolean = false
-
// ==================== Schema Clipping (Struct-Backed Types)
====================
/**
@@ -200,6 +183,37 @@ private[parquet] trait ParquetTypeOps extends Serializable
{
* Primitive types return None (no sub-fields to clip).
*/
def parquetStructSchema: Option[StructType] = None
+
+ // ==================== Vectorized Read ====================
+
+ /**
+ * Whether vectorized (batch) reading is supported for this type.
+ * Used by ParquetUtils.isBatchReadSupported. Default is false - types must
opt in
+ * by overriding to true. When false, Spark uses the row-based read path
(newConverter)
+ * which is always available.
+ *
+ * A type that returns true must also supply a batch decoder via
[[getVectorUpdater]]
+ * (dispatched from ParquetVectorUpdaterFactory.getUpdater); otherwise the
vectorized factory
+ * would not recognize it. TimeType returns true and overrides
getVectorUpdater accordingly.
+ *
+ * @param sqlConf the active SQL configuration
+ */
+ def isBatchReadSupported(sqlConf: SQLConf): Boolean = false
+
+ /**
+ * The vectorized (batch) [[ParquetVectorUpdater]] for this type, or None to
fall back to the
+ * built-in `ParquetVectorUpdaterFactory`. Dispatched (Spark DataType ->
ops) at the top of
+ * `ParquetVectorUpdaterFactory.getUpdater`, before its built-in cases.
+ *
+ * This and [[isBatchReadSupported]] form a two-way contract: returning Some
here without also
+ * returning true from isBatchReadSupported leaves the vectorized path
unreachable (the row path
+ * is used), while returning true from isBatchReadSupported without
supplying an updater here
+ * (and without legacy factory support for the type) routes into a factory
that does not
+ * recognize the type. A framework type that opts into vectorized reads must
do both.
+ *
+ * @param descriptor the Parquet column descriptor being read
+ */
+ def getVectorUpdater(descriptor: ColumnDescriptor):
Option[ParquetVectorUpdater] = None
}
/**
@@ -226,4 +240,13 @@ private[parquet] object ParquetTypeOps {
case _ => None
}
}
+
+ /**
+ * Java-friendly entry point for `ParquetVectorUpdaterFactory`: the
framework vectorized
+ * updater for `dt`, or null if `dt` is not framework-managed (so the
factory falls through
+ * to its built-in updaters).
+ */
+ private[parquet] def getVectorUpdaterOrNull(
+ dt: DataType, descriptor: ColumnDescriptor): ParquetVectorUpdater =
+ apply(dt).flatMap(_.getVectorUpdater(descriptor)).orNull
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOps.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOps.scala
index f155d35378a8..c868716e7fc9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOps.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOps.scala
@@ -17,6 +17,7 @@
package org.apache.spark.sql.execution.datasources.parquet.types.ops
+import org.apache.parquet.column.{ColumnDescriptor, Dictionary}
import org.apache.parquet.io.api.{Converter, RecordConsumer}
import org.apache.parquet.schema.{LogicalTypeAnnotation, Type, Types}
import org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit
@@ -26,7 +27,8 @@ import org.apache.parquet.schema.Type.Repetition
import org.apache.spark.sql.catalyst.expressions.SpecializedGetters
import org.apache.spark.sql.catalyst.util.DateTimeUtils
import org.apache.spark.sql.errors.QueryExecutionErrors
-import
org.apache.spark.sql.execution.datasources.parquet.{HasParentContainerUpdater,
ParentContainerUpdater, ParquetPrimitiveConverter}
+import
org.apache.spark.sql.execution.datasources.parquet.{HasParentContainerUpdater,
ParentContainerUpdater, ParquetPrimitiveConverter, ParquetVectorUpdater,
VectorizedValuesReader}
+import org.apache.spark.sql.execution.vectorized.WritableColumnVector
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.types.{DataType, TimeType}
@@ -104,6 +106,21 @@ case class TimeTypeParquetOps(t: TimeType) extends
ParquetTypeOps {
// ==================== Vectorized Read ====================
override def isBatchReadSupported(sqlConf: SQLConf): Boolean = true
+
+ // Only a canonical INT64 TIME(MICROS)/TIME(NANOS) column can be
vectorized-decoded as TimeType.
+ // Return None for anything else (INT32 TIME(MILLIS), raw INT64, INT64
TIMESTAMP, ...) so the
+ // factory falls through to its clean
SchemaColumnConvertNotSupportedException instead of silently
+ // mis-reading. This is the same compatibility check the row path uses
+ // (requireCompatibleParquetType), unifying the read guard across both
readers. The on-disk unit
+ // (MICROS vs NANOS) and the requested precision drive the conversion +
truncation.
+ override def getVectorUpdater(descriptor: ColumnDescriptor):
Option[ParquetVectorUpdater] = {
+ val parquetType = descriptor.getPrimitiveType
+ if (TimeTypeParquetOps.isCompatibleParquetType(parquetType)) {
+ Some(new TimeVectorUpdater(t.precision,
TimeTypeParquetOps.isNanosTime(parquetType)))
+ } else {
+ None
+ }
+ }
}
private[ops] object TimeTypeParquetOps {
@@ -122,33 +139,82 @@ private[ops] object TimeTypeParquetOps {
}
/**
- * Validates that a Parquet field can be decoded as TimeType. TimeType is
written as INT64 with
- * TIME(MICROS, isAdjustedToUTC=false) for precision 0..6 and TIME(NANOS,
isAdjustedToUTC=false)
- * for precision 7..9. On read, any INT64 TIME(MICROS) or TIME(NANOS) column
is accepted
- * regardless of the isAdjustedToUTC flag: Spark's zone-less TimeType
decodes the raw
- * time-of-day identically either way, matching the legacy
ParquetRowConverter guard (see
- * SPARK-57416). Any other encoding (raw INT64, INT32 TIME(MILLIS), INT64
TIMESTAMP(_),
- * decimal-annotated, etc.) cannot be decoded as TimeType - throw the same
error as the legacy
- * ParquetRowConverter path so reads fail loudly instead of silently
misinterpreting bytes.
+ * Whether a Parquet field is a canonical INT64 TIME(MICROS) (precision
0..6) or TIME(NANOS)
+ * (precision 7..9) column - the only encodings Spark can decode as
TimeType. The isAdjustedToUTC
+ * flag is intentionally ignored: Spark's TimeType is zone-less local time,
so the raw
+ * time-of-day decodes identically either way, matching the legacy
ParquetRowConverter guard
+ * (see SPARK-57416). Any other encoding (raw INT64, INT32 TIME(MILLIS),
INT64 TIMESTAMP(_),
+ * decimal-annotated, etc.) is incompatible. Shared by both the row-based
and vectorized read
+ * paths so they accept/reject the same set.
*/
- private[ops] def requireCompatibleParquetType(
- sparkType: TimeType, parquetType: Type): Unit = {
- val ok = parquetType.isPrimitive &&
+ private[ops] def isCompatibleParquetType(parquetType: Type): Boolean =
+ parquetType.isPrimitive &&
parquetType.asPrimitiveType.getPrimitiveTypeName == INT64 &&
(parquetType.getLogicalTypeAnnotation match {
case t: LogicalTypeAnnotation.TimeLogicalTypeAnnotation =>
- // Accept both MICROS (precision 0..6) and NANOS (precision 7..9),
and both
- // isAdjustedToUTC=false and =true. Spark's TimeType is zone-less
local time, so the
- // UTC-adjustment flag carries no extra information on read: the raw
time-of-day value
- // decodes identically either way. Mirroring the legacy
ParquetRowConverter guard keeps
- // the framework row-based read path consistent with the legacy and
vectorized readers.
- // SPARK-57416.
t.getUnit == TimeUnit.MICROS || t.getUnit == TimeUnit.NANOS
case _ => false
})
- if (!ok) {
+
+ /**
+ * Validates that a Parquet field can be decoded as TimeType on the
row-based path, throwing the
+ * same error as the legacy ParquetRowConverter so incompatible reads fail
loudly instead of
+ * silently misinterpreting bytes. See [[isCompatibleParquetType]] for the
accepted encodings.
+ */
+ private[ops] def requireCompatibleParquetType(
+ sparkType: TimeType, parquetType: Type): Unit = {
+ if (!isCompatibleParquetType(parquetType)) {
throw QueryExecutionErrors.cannotCreateParquetConverterForDataTypeError(
sparkType, parquetType.toString)
}
}
}
+
+/**
+ * Vectorized (batch) updater for TimeType: reads an INT64 TIME column into
the internal
+ * nanos-of-day representation and truncates it to the requested precision.
`fileStoresNanos`
+ * selects the on-disk unit - TIME(NANOS) stores nanos (identity),
TIME(MICROS) stores micros
+ * (converted to nanos). Mirrors the row-based newConverter path so the
vectorized and row-based
+ * readers agree; replaces the former
`ParquetVectorUpdaterFactory.TimeUpdater`, now owned by the
+ * type's ops.
+ */
+private[ops] class TimeVectorUpdater(precision: Int, fileStoresNanos: Boolean)
+ extends ParquetVectorUpdater {
+ private def toTruncatedNanos(value: Long): Long = {
+ val nanos = if (fileStoresNanos) value else
DateTimeUtils.microsToNanos(value)
+ // Same conversion + truncation as the row-based newConverter path, via
the shared
+ // (table-backed) DateTimeUtils.truncateTimeToPrecision, so both readers
stay in lock-step.
+ DateTimeUtils.truncateTimeToPrecision(nanos, precision)
+ }
+
+ override def readValues(
+ total: Int,
+ offset: Int,
+ values: WritableColumnVector,
+ valuesReader: VectorizedValuesReader): Unit = {
+ valuesReader.readLongs(total, values, offset)
+ var i = 0
+ while (i < total) {
+ values.putLong(offset + i, toTruncatedNanos(values.getLong(offset + i)))
+ i += 1
+ }
+ }
+
+ override def skipValues(total: Int, valuesReader: VectorizedValuesReader):
Unit =
+ valuesReader.skipLongs(total)
+
+ override def readValue(
+ offset: Int,
+ values: WritableColumnVector,
+ valuesReader: VectorizedValuesReader): Unit =
+ values.putLong(offset, toTruncatedNanos(valuesReader.readLong()))
+
+ override def decodeSingleDictionaryId(
+ offset: Int,
+ values: WritableColumnVector,
+ dictionaryIds: WritableColumnVector,
+ dictionary: Dictionary): Unit = {
+ val value = dictionary.decodeToLong(dictionaryIds.getDictId(offset))
+ values.putLong(offset, toTruncatedNanos(value))
+ }
+}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetIOSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetIOSuite.scala
index 6b41da841e56..4439062ccfdc 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetIOSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetIOSuite.scala
@@ -2009,6 +2009,79 @@ class ParquetIOSuite extends ParquetTest with
SharedSparkSession {
}
}
+ test("SPARK-55444: vectorized read rejects an incompatible encoding
requested as TimeType") {
+ // TimeTypeParquetOps.getVectorUpdater returns None for any encoding other
than INT64
+ // TIME(MICROS/NANOS), so the vectorized factory falls through to a clean
+ // SchemaColumnConvertNotSupportedException (surfaced as FAILED_READ_FILE)
instead of
+ // silently mis-decoding - e.g. running readLongs over an INT32 column.
This is the
+ // end-to-end counterpart of the unit-level reject assertions in
TimeTypeParquetOpsSuite,
+ // pinned on the actual vectorized reader (the path the descriptor guard
protects).
+ val readSchema = new StructType().add("c", TimeType())
+ // (Parquet column definition, writer of one matching-primitive value for
that column)
+ val cases: Seq[(String, SimpleGroup => Unit)] = Seq(
+ ("required int32 c(TIME(MILLIS,false));", _.add(0, 0)), // wrong
primitive
+ ("required int64 c;", _.add(0, 0L)), // no TIME
annotation
+ ("required int64 c(TIMESTAMP(MICROS,false));", _.add(0, 0L)) // wrong
annotation
+ )
+ for ((column, addValue) <- cases) {
+ val schema = MessageTypeParser.parseMessageType(s"message root {\n
$column\n}")
+ withTempDir { dir =>
+ val tablePath = new
Path(s"${dir.getCanonicalPath}/incompatible.parquet")
+ val writer = createParquetWriter(schema, tablePath, dictionaryEnabled
= false)
+ (0 until 10).foreach { _ =>
+ val record = new SimpleGroup(schema)
+ addValue(record)
+ writer.write(record)
+ }
+ writer.close
+
+ withSQLConf(SQLConf.PARQUET_VECTORIZED_READER_ENABLED.key -> "true") {
+ val e = intercept[SparkException] {
+ spark.read.schema(readSchema).parquet(tablePath.toString).collect()
+ }
+ assert(e.getCondition ===
"FAILED_READ_FILE.PARQUET_COLUMN_DATA_TYPE_MISMATCH",
+ s"[$column] expected a clean column-type-mismatch, got
${e.getCondition}")
+ }
+ }
+ }
+ }
+
+ test("SPARK-55444: vectorized read of a nullable TIME(NANOS) column
(single-value path)") {
+ // A nullable (OPTIONAL) column interleaves def-levels, splitting value
runs into sub-batch
+ // lengths that drive VectorizedRleValuesReader down the runLen == 1 path
- i.e.
+ // TimeVectorUpdater.readValue / decodeSingleDictionaryId. The existing
REQUIRED TIME tests
+ // only exercise the bulk readValues path, so this closes the single-value
decode gap (and,
+ // via withAllParquetReaders, cross-checks the row-based reader on nulls).
+ val schema = MessageTypeParser.parseMessageType(
+ """message root {
+ | optional int64 time_nanos(TIME(NANOS,false));
+ |}""".stripMargin)
+ val readSchema = new StructType().add("time_nanos",
TimeType(TimeType.NANOS_PRECISION))
+ val lt = LocalTime.of(23, 59, 59, 123456789)
+
+ for (dictEnabled <- Seq(true, false)) {
+ withTempDir { dir =>
+ val tablePath = new
Path(s"${dir.getCanonicalPath}/times_nullable.parquet")
+ val numRecords = 100
+ val writer = createParquetWriter(schema, tablePath, dictionaryEnabled
= dictEnabled)
+ (0 until numRecords).foreach { i =>
+ val record = new SimpleGroup(schema)
+ // Every 7th row is null (the field is simply omitted): interleaves
null/value runs.
+ if (i % 7 != 0) record.add(0, localTime(23, 59, 59, 123456, 789))
+ writer.write(record)
+ }
+ writer.close
+
+ withAllParquetReaders {
+ val df = spark.read.schema(readSchema).parquet(tablePath.toString)
+ assertResult(df.schema)(readSchema)
+ val expected = (0 until numRecords).map { i => if (i % 7 == 0)
Row(null) else Row(lt) }
+ checkAnswer(df, expected)
+ }
+ }
+ }
+ }
+
// Deterministic INT32 sample shared by the INT32 widening tests below.
Mixes sign,
// zero, and MIN/MAX boundaries to catch sign-extension and precision
regressions.
private def widenSampleAt(i: Int): Int = i % 5 match {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterBenchmark.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterBenchmark.scala
index a0664fa5780c..f620d5ee4239 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterBenchmark.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterBenchmark.scala
@@ -45,7 +45,7 @@ import org.apache.spark.sql.types._
* (Boolean, Byte, Short, Integer, Long, Float, Double, Binary).
* B. Type-converting Updaters -- per-row read+convert+write loops.
* `IntegerToLong`, `IntegerToDouble`, `FloatToDouble`,
`DateToTimestampNTZ`,
- * `DowncastLong`, `LongAsNanos`.
+ * `DowncastLong`, `TimeVectorUpdater`.
* C. Rebase Updaters -- date/timestamp legacy-calendar rebase variants.
* `IntegerWithRebase` (DATE), `LongWithRebase` (TIMESTAMP_MICROS),
* `LongAsMicros`, `DateToTimestampNTZWithRebase`, `LongAsMicrosRebase`.
@@ -264,7 +264,7 @@ object ParquetVectorUpdaterBenchmark extends BenchmarkBase {
TimestampNTZType,
descriptor(PrimitiveTypeName.INT32, LogicalTypeAnnotation.dateType()),
longVec, intBytes)
- addReadValuesCase(benchmark, "LongAsNanosUpdater (TimeType)",
+ addReadValuesCase(benchmark, "TimeVectorUpdater (TimeType)",
TimeType(),
descriptor(PrimitiveTypeName.INT64,
LogicalTypeAnnotation.timeType(false,
LogicalTypeAnnotation.TimeUnit.MICROS)),
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterSuite.scala
index 13e9376fb6fa..85a41d5ed296 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetVectorUpdaterSuite.scala
@@ -28,6 +28,7 @@ import org.apache.parquet.schema.Type.Repetition
import org.apache.spark.SparkFunSuite
import org.apache.spark.sql.catalyst.util.DateTimeUtils
+import
org.apache.spark.sql.execution.datasources.SchemaColumnConvertNotSupportedException
import org.apache.spark.sql.execution.vectorized.OnHeapColumnVector
import org.apache.spark.sql.types._
@@ -493,4 +494,31 @@ class ParquetVectorUpdaterSuite extends SparkFunSuite {
-86_400_000_000L) // 1969-12-31 00:00:00 UTC
assert(readViaDateToTimestampNTZUpdater(input) === expected)
}
+
+ test("SPARK-55444: factory rejects an incompatible TIME encoding (no silent
mis-read)") {
+ // getVectorUpdaterOrNull returns null for any encoding other than INT64
TIME(MICROS/NANOS),
+ // so ParquetVectorUpdaterFactory.getUpdater - the entry point the
vectorized reader calls -
+ // falls through to a clean SchemaColumnConvertNotSupportedException
rather than silently
+ // decoding (e.g. readLongs over an INT32 column). Guarding the factory
entry point directly
+ // means a future broad TimeType arm / catch-all that reintroduced the
silent mis-read would
+ // fail here, not just at the framework-hook unit level.
+ val incompatible = Seq(
+ // INT32 TIME(MILLIS): wrong primitive width.
+ Types.primitive(PrimitiveTypeName.INT32, Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.timeType(false,
LogicalTypeAnnotation.TimeUnit.MILLIS))
+ .named("col"),
+ // raw INT64 with no TIME annotation.
+ Types.primitive(PrimitiveTypeName.INT64,
Repetition.OPTIONAL).named("col"),
+ // INT64 carrying a TIMESTAMP (not TIME) annotation.
+ Types.primitive(PrimitiveTypeName.INT64, Repetition.OPTIONAL)
+ .as(LogicalTypeAnnotation.timestampType(false,
LogicalTypeAnnotation.TimeUnit.MICROS))
+ .named("col")
+ )
+ incompatible.foreach { pt =>
+ val desc = new ColumnDescriptor(Array("col"), pt, 0, 1)
+ intercept[SchemaColumnConvertNotSupportedException] {
+ newFactory(desc).getUpdater(desc, TimeType())
+ }
+ }
+ }
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOpsSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOpsSuite.scala
index fa44297103ea..363a21e7dec9 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOpsSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/types/ops/TimeTypeParquetOpsSuite.scala
@@ -17,16 +17,20 @@
package org.apache.spark.sql.execution.datasources.parquet.types.ops
+import org.apache.parquet.column.ColumnDescriptor
import org.apache.parquet.schema.{LogicalTypeAnnotation, Type, Types}
import org.apache.parquet.schema.LogicalTypeAnnotation.TimeUnit
import org.apache.parquet.schema.PrimitiveType.PrimitiveTypeName.{INT32, INT64}
import org.apache.parquet.schema.Type.Repetition.REQUIRED
import org.apache.spark.{SparkFunSuite, SparkRuntimeException}
-import org.apache.spark.sql.types.TimeType
+import org.apache.spark.sql.types.{IntegerType, TimeType}
/**
- * Unit tests for [[TimeTypeParquetOps.requireCompatibleParquetType]].
+ * Unit tests for [[TimeTypeParquetOps]]'s Parquet read guards - both the
row-based
+ * [[TimeTypeParquetOps.requireCompatibleParquetType]] and the vectorized-read
+ * getVectorUpdater / getVectorUpdaterOrNull dispatch, which share the same
+ * compatible-encoding check so the two readers accept and reject the same set.
*
* TimeType is stored in Parquet as INT64 TIME(MICROS, isAdjustedToUTC=false)
for precision
* 0..6 and INT64 TIME(NANOS, isAdjustedToUTC=false) for precision 7..9. The
read-path guard
@@ -129,6 +133,40 @@ class TimeTypeParquetOpsSuite extends SparkFunSuite {
// the TIME annotation; the raw-INT64 / TIMESTAMP / DECIMAL / group tests
// above already exercise the !isPrimitive and "non-TIME annotation"
branches.
+ // ---------- vectorized read updater ----------
+
+ private def timeColumn(unit: TimeUnit): ColumnDescriptor =
+ new ColumnDescriptor(
+ Array("c"),
+ Types.primitive(INT64,
REQUIRED).as(LogicalTypeAnnotation.timeType(false, unit)).named("c"),
+ 0, 0)
+
+ test("getVectorUpdater returns a framework updater for TimeType (micros and
nanos)") {
+
assert(TimeTypeParquetOps(timeMicros).getVectorUpdater(timeColumn(TimeUnit.MICROS)).isDefined)
+
assert(TimeTypeParquetOps(timeNanos).getVectorUpdater(timeColumn(TimeUnit.NANOS)).isDefined)
+ // Java-friendly companion entry point used by ParquetVectorUpdaterFactory.
+ assert(ParquetTypeOps.getVectorUpdaterOrNull(timeMicros,
timeColumn(TimeUnit.MICROS)) != null)
+ }
+
+ test("getVectorUpdater returns None for incompatible encodings (clean
reject, vectorized path)") {
+ val int32Millis = Types.primitive(INT32, REQUIRED)
+ .as(LogicalTypeAnnotation.timeType(false, TimeUnit.MILLIS)).named("c")
+ val rawInt64 = Types.primitive(INT64, REQUIRED).named("c")
+ val int64Timestamp = Types.primitive(INT64, REQUIRED)
+ .as(LogicalTypeAnnotation.timestampType(false,
TimeUnit.MICROS)).named("c")
+ // None -> the factory falls through to a clean
SchemaColumnConvertNotSupportedException,
+ // matching the row-path reject set (requireCompatibleParquetType),
instead of silently
+ // mis-decoding (e.g. readLongs over an INT32 column).
+ Seq(int32Millis, rawInt64, int64Timestamp).foreach { field =>
+ val descriptor = new ColumnDescriptor(Array("c"), field, 0, 0)
+
assert(TimeTypeParquetOps(timeMicros).getVectorUpdater(descriptor).isEmpty)
+ }
+ }
+
+ test("getVectorUpdaterOrNull returns null for non-framework types") {
+ assert(ParquetTypeOps.getVectorUpdaterOrNull(IntegerType, null) == null)
+ }
+
// ---------- helper ----------
private def assertRejects(sparkType: TimeType, field: Type): Unit = {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]