Andrew Grove created SPARK-60262:
------------------------------------

             Summary: Parquet field-id reads return zeros or uninitialized 
memory when an id match and a name match select the same column
                 Key: SPARK-60262
                 URL: https://issues.apache.org/jira/browse/SPARK-60262
             Project: Spark
          Issue Type: Bug
          Components: SQL
    Affects Versions: 5.0.0
            Reporter: Andrew Grove


_Drafted with LLM assistance (Claude Code); verified by running the 
reproduction below on Spark 4.1.3, and by reading the same code in Spark 4.2.0._

h3. Problem

With {{spark.sql.parquet.fieldId.read.enabled=true}}, 
{{ParquetReadSupport.clipParquetGroupFields}} resolves each requested field on 
its own. A field that carries a field id resolves by id ({{matchIdField}}), and 
any other field resolves by name. Two requested fields can therefore select the 
same Parquet column. Take a file column {{A}} with field id 1: a requested 
{{x}} with id 1 selects it by id, and a requested {{a}} without an id selects 
it by name. Primitive types are not clipped, so the requested Parquet schema 
lists {{A}} twice.

Neither reader returns {{A}}'s values for both fields, and the vectorized 
reader returns wrong values without an error:

* *Vectorized reader.* {{VectorizedParquetRecordReader}} builds one 
{{VectorizedColumnReader}} per requested leaf. Both leaves have the same 
{{ColumnDescriptor}}. parquet-java's {{ColumnChunkPageReadStore.getPageReader}} 
looks up one {{ColumnChunkPageReader}} per descriptor, and its {{readPage}} 
polls a queue. The first reader drains the pages. For the second, 
{{readPage()}} gets no page and returns {{-1}}, and {{readBatch}} stops as if 
it had read every page. Its vector is never written, and the null mask isn't 
either, so the rows read as non-null:
** With on-heap vectors, the default, they read {{0}}.
** With off-heap vectors they read uninitialized memory. Off-heap vectors are 
controlled by {{spark.sql.columnVector.offheap.enabled}}, which follows 
{{spark.memory.offHeap.enabled}} from Spark 4.0 on.
** Whichever field comes first in the requested schema gets the values.
* *parquet-mr reader* ({{spark.sql.parquet.enableVectorizedReader=false}}). 
{{ParquetRowConverter}} maps the clipped Parquet fields back to requested 
fields by id first. Both copies of {{A}} carry id 1, so both map to {{x}}, and 
{{a}} reads null.

h3. Reproduce

{code:scala}
import org.apache.spark.sql.Row
import org.apache.spark.sql.types._

val id1 = new MetadataBuilder().putLong("parquet.field.id", 1L).build()
spark
  .createDataFrame(
    spark.sparkContext.parallelize(Seq(Row(1), Row(2), Row(3)), 1),
    StructType(Seq(StructField("A", IntegerType, nullable = true, id1))))
  .write
  .parquet("/tmp/field-id-twice")

spark.conf.set("spark.sql.parquet.fieldId.read.enabled", "true")
val x = StructField("x", IntegerType, nullable = true, id1)
val a = StructField("a", IntegerType, nullable = true)
spark.read.schema(StructType(Seq(x, a))).parquet("/tmp/field-id-twice").show()
{code}

||Requested fields||Vectorized, on-heap vectors (default)||Vectorized, off-heap 
vectors||parquet-mr||
|{{x}} (id 1), {{a}}|{{x}} = 1, 2, 3; {{a}} = 0, 0, 0|{{x}} = 1, 2, 3; {{a}} = 
0, 0, 1634492735|{{x}} = 1, 2, 3; {{a}} = null|
|{{a}}, {{x}} (id 1)|{{a}} = 1, 2, 3; {{x}} = 0, 0, 0|{{a}} = 1, 2, 3; {{x}} = 
stale memory|{{a}} = null; {{x}} = 1, 2, 3|
|struct {{s}} holding {{x}} (id 1), {{a}}|{{a}} = 0, 0, 0|{{a}} = stale 
memory|{{a}} = null|

For the struct case, write {{A}} inside a struct {{s}} and request {{s}} with 
the fields {{x}} and {{a}}. The off-heap values change between runs 
({{1634492735}}, {{489223743}}, {{1403037696}}, ...). These results are from 
Spark 4.1.3, where nested column vectorization is on by default.

h3. Expected behavior

Both fields read {{1, 2, 3}}, because {{clipParquetGroupFields}} resolves each 
of them to {{A}}. Failing the read would also be reasonable. Returning zeros or 
uninitialized memory without an error is not.

h3. Possible fix

* *Guard.* Make {{VectorizedColumnReader.readBatch}} fail, instead of 
returning, when it runs out of pages with rows still to read in a column that 
isn't repeated. That would turn the wrong values into an error, whatever else 
changes.
* *Vectorized reader.* In {{VectorizedParquetRecordReader}}, read a column once 
when several requested leaves resolve to it, and fill each of their vectors 
from that read.
* *parquet-mr reader.* {{clipParquetGroupFields}} returns one clipped field per 
requested field, in requested order. So {{ParquetRowConverter}} could pair them 
by position instead of by id or name. Someone who knows why it matches by id 
would need to confirm that is safe.

h3. Context

I found this while comparing DataFusion Comet's native Parquet scan with Spark 
for this shape ([apache/datafusion-comet issue 
6994|https://github.com/apache/datafusion-comet/issues/6994]). Field-id reads 
arrived in Spark 3.3 ([apache/spark PR 
35385|https://github.com/apache/spark/pull/35385], SPARK-38094). The shape 
needs a requested struct that mixes fields with and without field ids, so it is 
uncommon: Iceberg and Delta give every column an id.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to