fix: validate Parquet VARIANT annotation against the requested read type by 0lai0 · Pull Request #5932 · apache/datafusion-comet · GitHub
Skip to content

fix: validate Parquet VARIANT annotation against the requested read type - #5932

Open
0lai0 wants to merge 2 commits into
apache:mainfrom
0lai0:fix-5741-variant-annotation-validation
Open

0lai0 wants to merge 2 commits into
apache:mainfrom
0lai0:fix-5741-variant-annotation-validation

Conversation

@0lai0

@0lai0 0lai0 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #5741.

Rationale for this change

Reading a VARIANT-annotated Parquet field as an ordinary struct<value binary, metadata binary> silently returned the storage bytes. Spark rejects that read in ParquetToSparkSchemaConverter.convertGroupField with _LEGACY_ERROR_TEMP_3071 unless spark.sql.parquet.ignoreVariantAnnotation is set.

CometScanRule cannot catch it: it only sees the requested schema, and a hand-written struct carries none of the VariantMetadata that isVariantStruct looks for. The annotation lives in the file, so the check has to happen in the native reader.

What changes are included in this PR?

arrow-rs surfaces the annotation as the arrow.parquet.variant Arrow extension type, and Comet's serde marks a requested VariantType the same way. check_variant_annotation compares the two sides symmetrically in SparkPhysicalExprAdapterFactory::create, so a marked request stays a legitimate Variant read and only an unmarked request against an annotated file is rejected.
Matching on value/metadata child names instead would misclassify ordinary structs, which #5741 rules out.

  • spark.sql.parquet.ignoreVariantAnnotation plumbed through NativeScanCommon, read by key since the conf is 4.1-only while this file compiles against 3.4 through 4.1.
  • Gated to Spark 4.1+. The converter branch and the conf both arrived in 4.1, so on 3.4, 3.5 and 4.0 the check is off rather than inventing a failure Spark does not have.
  • Recurses through struct fields, list elements and map values, pairing nested fields by field id the way spark_parquet_convert does.
  • Runs at file open, before any row group, matching Spark rejecting during schema conversion. An empty file fails too.
  • SparkError::ParquetVariantAnnotationMismatch converted by the 4.x shim into Spark's AnalysisException wrapped in FAILED_READ_FILE.
  • dev/diffs/4.1.3.diff regenerated per spark-sql-tests.md to drop the IgnoreComet exclusion.

Known gap, documented on is_variant_marked: the extension type carries no spec version, so with ignoreVariantAnnotation=true on a non-v1 annotation Comet reads a file Spark refuses. Both engines reject it when the conf is off, with different error classes.

How are these changes tested?

ParquetVariantShreddingSuite / variant logical type annotation - ignore variant annotation is no longer excluded. I ran the suite locally against Spark 4.1.3 with ENABLE_COMET=true: 7 tests pass, none ignored. With the check disabled it fails with Expected exception org.apache.spark.SparkException to be thrown, but no exception was thrown, the failure #5741
reports, so the suite is exercising this change.

15 native tests in schema_adapter.rs cover rejection at each nesting shape, a real VARIANT(1) file written through the low-level writer, ignoreVariantAnnotation=true, a marked Variant request not being rejected, an empty file, nested field-id resolution, and probes asserting the annotation still reaches create as an extension type.

@github-actions github-actions Bot added bug Something isn't working area:scan Parquet scan / data reading area:Iceberg area:joins Join operators and dynamic filter pushdown labels Sep 14, 2026

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correctness

This fixes the missing error in #5741: a manually declared ordinary struct can reach the native reader even when the Parquet field is annotated as VARIANT. Checking the physical extension marker against the requested marker is the right distinction. Matching only the storage child names would incorrectly classify ordinary structs. The change carries the ignore setting through the scan protocol, gates the Parquet datasource check to Spark 4.1+, and translates the new error through the Spark 4.x shim.

I found two [P2] correctness issues, detailed inline. The factory validates the full relation schema rather than the actual projection, so selecting an unrelated scalar column can fail because an omitted column has a VARIANT annotation. The map recursion also matches the entries children by name or field ID, while the reader consumes map keys and values by position. A differently named physical value field can therefore bypass the annotation check.

The existing field-ID remap preserves physical metadata, and absent requested fields do not acquire an annotation to reject. Marked Variant requests and the explicit ignore setting remain accepted by the guard. The PR documents a remaining version limitation: the Arrow marker loses the annotation's spec version. That limitation predates this guard's acceptance path and is not counted as a new finding here.

Validation

Reviewed 03f9da00857d469afb7dccdf6bcfdd1f3dd8dcb4 against 38a6ec362096c7c205b379c5a5d7a7fc81c6b9e9, including all 13 changed files. I checked maintained Spark 3.5/4.0 sources for schema clipping and map matching, plus the exact DataFusion 55.1.0 and parquet-rs 59.3.0 sources verified against the lockfile checksums. An isolated Rust probe compiled and exercised the exact guard and factory block with minimal type doubles. It confirmed the two control-flow cases and checked marked requests, the ignore setting, and renamed or missing field IDs. It did not execute Arrow, DataFusion, JNI, Spark, or a real Parquet scan. Maintained Spark 3.4/4.1 branches were unavailable, so no coverage of those sources is claimed.

The PR adds 15 native cases and reports seven passing Spark 4.1.3 suite tests. I have not independently rerun them. At the September 15, 04:40 UTC refresh, only labeling passed. CI, CodeQL and the Delta gate were approval-required with zero jobs. The cached merge has the exact reviewed base/head parents and the same whole tree as the head, but there is no executed product CI to credit.

Performance

The guard walks in-memory schema metadata when the adapter is created. It adds no data-row loop or metadata fetch. It does allocate paths and lookup maps while visiting nested fields, including currently unrequested roots. Restricting validation to the actual read schema addresses that unnecessary work together with the first correctness issue. The existing ASCII fast path and cached non-ASCII name folding remain available. No benchmark or measured speedup is claimed for this validation change.

Design

The native reader is the appropriate boundary because the requested Spark schema alone cannot reveal a file annotation. An eager check also preserves rejection of an empty file when the incompatible field is requested. The check needs the same selection rules as the read itself: the requested schema for its scope, field-ID/name resolution for structs, and positional resolution for map children. Those changes address the findings without restricting legitimate projections or moving the error into per-row execution.

Abstraction & complexity

The option and structured error carrier fit the existing scan and error-conversion interfaces. The new recursive matcher duplicates struct-resolution logic already centralized in match_struct_fields. Reusing that helper for structs and handling map children explicitly would keep validation aligned with conversion, including ambiguity handling. The file-writing tests and probe factory have a useful purpose: they distinguish a real Parquet annotation reaching the adapter from an Arrow-schema hint that merely looks annotated. Their missing boundary cases are an omitted annotated root and a differently named map value, which the inline findings request.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correctness

[P2] Could this validation use the actual requested read schema rather than every field of logical_file_schema? CometNativeScan keeps an ordinary struct in nativeDataSchema even when it is unprojected, and init_datasource_exec passes that full schema to ParquetSource with a separate projection. DataFusion 55.1.0 consequently calls this factory with the full schema before rewriting the projection. For a file with id INT, v VARIANT(1), a read declared as id INT, v STRUCT<value BINARY, metadata BINARY> followed by .select("id") now fails on v, although no read of v was requested. Spark clips its Parquet schema to the requested columns before conversion. Please keep the eager check for requested fields, including empty files, and add a regression covering an omitted annotated root.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correctness

[P2] Could the map branch validate the key and value by position instead of recursing through the entries struct's name/field-ID matcher? The requested Arrow map uses key/value, but parquet-rs retains the file's child names. If the second child is named payload and carries VARIANT(1), the struct matcher finds no value field and skips its annotation. Both check_conversion and parquet_convert_map_to_map still read that second child positionally, so the incompatible plain-struct map value gets past this new guard. Spark's map reader also selects children 0 and 1 rather than requiring those names. Please use the same positional pairing here and cover an annotated map value whose Parquet field name differs from value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:Iceberg area:joins Join operators and dynamic filter pushdown area:scan Parquet scan / data reading bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parquet Variant annotation validation is bypassed when reading as an ordinary struct

2 participants