Skip to content

Parquet: Fix nested initial default applied when ancestor struct is null - #17320

Merged
amogh-jahagirdar merged 7 commits into
apache:mainfrom
amogh-jahagirdar:fix-nested-default-null-struct
Sep 10, 2026
Merged

amogh-jahagirdar merged 7 commits into
apache:mainfrom
amogh-jahagirdar:fix-nested-default-null-struct

Conversation

@amogh-jahagirdar

@amogh-jahagirdar amogh-jahagirdar commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

When a query projects only a nested field that has an initial default and doesn't include the struct's real fields, PruneColumns reduces the struct to an empty group (which I think is correct, expected behavior for that abstraction). That discards the file leaf columns whose definition levels carry if the struct itself is null or not, so a null struct was incorrectly materialized as a struct of default values. in SQL

select struct.field from t1...

where struct is null but struct.field has a default value would surface the default value instead of null, which is not spec compliant.

To address this we solve this in two parts:

  1. Retain 1 leaf that we probe for (even if it's not being projected) just so we have a definition level to work with. I don't think there's any other way to get definition levels because those are carried at the very leaf level of a struct.

  2. The default value constant reader uses that real leafs definition level to derive if the parent struct is null or not so that we surface the spec compliant results.

@amogh-jahagirdar

Copy link
Copy Markdown
Contributor Author

Spark/Flink tests will fail because their row oriented readers still need to be updated to have a similar fix as to what's done in BaseParquetReader. But I wnated to talk through the solution first with others.

@amogh-jahagirdar
amogh-jahagirdar force-pushed the fix-nested-default-null-struct branch from 9a0a571 to 5adf95b Compare July 21, 2026 14:56
@pvary

pvary commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Maybe we should test based on the spec example:

Default values for fields that are nested structs must not contain default values for the struct's fields (sub-fields).

For example, a struct column point with fields x (default 0) and y (default 0) can be defaulted to {"x": 0, "y": 0} or null. A non-null default is stored by setting initial-default or write-default to an empty struct ({}) that will use field values set from each field's initial-default or write-default, respectively.

If I understand correctly, the {} default is not working - this might worth a different PR as the {} is currently rejected.

@Tishj

Tishj commented Jul 23, 2026

Copy link
Copy Markdown

Maybe we should test based on the spec example:

Default values for fields that are nested structs must not contain default values for the struct's fields (sub-fields).

For example, a struct column point with fields x (default 0) and y (default 0) can be defaulted to {"x": 0, "y": 0} or null. A non-null default is stored by setting initial-default or write-default to an empty struct ({}) that will use field values set from each field's initial-default or write-default, respectively.

If I understand correctly, the {} default is not working - this might worth a different PR as the {} is currently rejected.

#16596

@nssalian nssalian added this to the Iceberg 1.12.0 milestone Aug 18, 2026
@amogh-jahagirdar
amogh-jahagirdar force-pushed the fix-nested-default-null-struct branch from 1048894 to 936efed Compare August 19, 2026 03:51
@amogh-jahagirdar
amogh-jahagirdar force-pushed the fix-nested-default-null-struct branch from 8e8ef58 to 4c6c948 Compare August 19, 2026 14:31
@@ -182,4 +184,154 @@ public void testTwoLevelList() throws IOException {
assertThat(Lists.newArrayList(reader)).hasSize(1);
}
}

@Test

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

i'm a little skeptical this test is needed now, feels a bit duplicative

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I did keep a more "representative" test but dropped the other one; the rationale for keeping the more realistic test for generic reader was that even though all of the other tests share the same Parquet reader logic, there's still some logic in the different implementations which adapt to the engine record type (e.g. FlinkParquetReaders/ SparkParquetReaders) which compute things like readersById and invoke the newly added structFieldReader.

The Generic case is another implementation of that and we'd want to catch any bugs in that middle layer that may only apply for generic reader but don't impact the Spark/Flink ones.

@amogh-jahagirdar
amogh-jahagirdar force-pushed the fix-nested-default-null-struct branch from 77d74b3 to cdba2fb Compare September 3, 2026 15:37
@nssalian
nssalian requested a review from pvary September 3, 2026 22:04
@@ -129,12 +131,94 @@ public static Type fieldType(GroupType group, String name) {

public static MessageType pruneColumns(MessageType fileSchema, Schema expectedSchema) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This method is called from ParquetReadSupport.init which is used by parquet-mr. There the returned reader context returns the newly projected extra columns and these columns are returned if no createReaderFunc has been set.

If this is a valid, supported scenario (parquet-mr, no createReaderFunc), then we should fix it as the returned values will change.

This could help to repro in TestGenericData:

  private static final Schema NESTED_MULTI_LEAF_SCHEMA =
      new Schema(
          Types.NestedField.required(1, "id", Types.LongType.get()),
          Types.NestedField.optional(
              2,
              "nested",
              Types.StructType.of(
                  Types.NestedField.required(3, "big", Types.StringType.get()),
                  Types.NestedField.required(4, "inner", Types.StringType.get()))));

  private static final Schema NESTED_DEFAULT_ONLY_READ_SCHEMA =
      new Schema(
          Types.NestedField.required(1, "id", Types.LongType.get()),
          Types.NestedField.optional("nested")
              .withId(2)
              .ofType(
                  Types.StructType.of(
                      Types.NestedField.optional("added")
                          .withId(9)
                          .ofType(Types.StringType.get())
                          .withInitialDefault(Literal.of("US"))
                          .build()))
              .build());

  @Test
  public void testGenericReadDoesNotMaterializeUnprojectedColumns() throws IOException {
    Types.StructType nestedType =
        NESTED_MULTI_LEAF_SCHEMA.findField("nested").type().asStructType();

    Record present = GenericRecord.create(NESTED_MULTI_LEAF_SCHEMA);
    present.setField("id", 1L);
    Record presentNested = GenericRecord.create(nestedType);
    presentNested.setField("big", "b");
    presentNested.setField("inner", "a");
    present.setField("nested", presentNested);

    Record nullNested = GenericRecord.create(NESTED_MULTI_LEAF_SCHEMA);
    nullNested.setField("id", 2L);
    nullNested.setField("nested", null);

    OutputFile output = new InMemoryOutputFile();
    try (FileAppender<Record> appender =
        Parquet.write(output)
            .schema(NESTED_MULTI_LEAF_SCHEMA)
            .createWriterFunc(GenericParquetWriter::create)
            .build()) {
      appender.add(present);
      appender.add(nullNested);
    }

    // the generic read path has no reader function and materializes the pruned projection directly
    List<org.apache.avro.generic.GenericRecord> rows;
    try (CloseableIterable<org.apache.avro.generic.GenericRecord> reader =
        Parquet.read(output.toInputFile()).project(NESTED_DEFAULT_ONLY_READ_SCHEMA).build()) {
      rows = Lists.newArrayList(reader);
    }

    assertThat(rows).hasSize(2);
    for (org.apache.avro.generic.GenericRecord row : rows) {
      org.apache.avro.generic.GenericRecord nested =
          (org.apache.avro.generic.GenericRecord) row.get("nested");
      if (nested != null) {
        assertThat(nested.getSchema().getFields())
            .extracting(org.apache.avro.Schema.Field::name)
            .as("read records must not expose columns outside the projection")
            .doesNotContain("big", "inner");
      }
    }
  }

@amogh-jahagirdar amogh-jahagirdar Sep 6, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure this is a valid case, or at least a case we need to concern ourselves with in my opinion. The parquet read path where a reader function is not passed in, isn't invoked anywhere within the codebase and all the engines pass through a reader function. That path never supported default values let alone this specific handling of a default value case. Though it maybe a good idea in a separate PR to block reads through that path if there are initial default values and a reader function isn't specified, but considering it's been there and hasn't really been a practical issue I'd prefer to leave it for a separate PR>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think eventually we will have to make sure that the prune side and the read side use the same predicates. Otherwise we will just messing around and hoping 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, the logic to choose the leaf is already shared but the conditions for does a given struct need a presence column wasn't shared. We'll need to plumb through readersById through structReader everywhere to unify that latter check. let me see how invasive that is first, if it is, maybe best to do that in a follow on

@amogh-jahagirdar
amogh-jahagirdar force-pushed the fix-nested-default-null-struct branch from 8858ea7 to c4a182d Compare September 6, 2026 15:18
When a query projects only a nested field that has an initial default (or a
metadata/partition constant) and drops the struct's real fields, PruneColumns
reduces the struct to an empty group. That discards the file leaf columns whose
definition levels carry the struct's per-row null-ness, so a null struct was
incorrectly materialized as a struct of default values.

To address this we solve this in two parts:

1. Retain 1 leaf that we probe for (even if it's not being projected) just so we have a definition level to work with

2. The default value constant reader uses that real leafs definition level to derive if the parent struct is null or not so that we surface the spec compliant results.
pvary caught this: the leaf we borrow to signal a constant-only
struct's null-ness could sit behind a LIST/MAP, so it doesn't produce
one value per struct occurrence and desyncs the reader. Share the
selection logic between schema pruning and reader construction so
they agree on the same leaf, restrict it to repetition-safe leaves,
and fail loudly instead of silently falling back when none exists.
… the tracking of struct presence rather than individual field readers, addressing case where there isn't a default value
The presence column tracking whether a struct is present can now be a leaf
nested inside a list or map: PresenceReader drains the row's repeated values,
and selectPresenceColumn picks the shallowest leaf rather than failing when
the struct has no flat leaf.
@amogh-jahagirdar
amogh-jahagirdar force-pushed the fix-nested-default-null-struct branch from d7a198a to 7d7a95e Compare September 10, 2026 03:02
return null;
}

return leafColumns(fileSchema, path).stream()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

should we get the leafColumn from the caller?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We still calculate the leafColumns(fileSchema, path) twice. Here and in struct(expected, fields)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah sorry, I had AI try and address these nits and I think it missed this :( . Latest update should avoid recomputing the leaf columns, we just pass it through selectPresenceColumns

amogh-jahagirdar and others added 2 commits September 10, 2026 07:50
Inline PresenceReader construction instead of the single-caller
withPresence helper, and make leafColumns private since it is only
used within ParquetSchemaUtil.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmF9i2p9FfujkAogdhcDKc
…tReader

SparkParquetReaders for v4.2 built struct readers inline instead of
calling ParquetValueReaders.structReader, so it never got the presence
column that makes a null struct's definition level visible when its
only remaining leaf reads a constant (default or metadata). Every
struct/list/map default and null-struct test failed against Parquet on
4.2 as a result; other engines and Spark versions were unaffected. Fix
it to match v4.1 and route through the shared struct reader.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmF9i2p9FfujkAogdhcDKc

@pvary pvary left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Well done @amogh-jahagirdar!
Small nits, and +1.
We can get the others in follow-ups

Compute the leaf list once in struct() and pass it to
selectPresenceColumn instead of recomputing it there.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmF9i2p9FfujkAogdhcDKc
@amogh-jahagirdar

Copy link
Copy Markdown
Contributor Author

Thanks for the great reviews @pvary , really helpful in catching subtle issues! I think this should be good to go in for the 1.12 release, so i'll merge.

@amogh-jahagirdar
amogh-jahagirdar merged commit 7d0f3b8 into apache:main Sep 10, 2026
42 checks passed
andygrove added a commit to andygrove/datafusion-comet that referenced this pull request Oct 17, 2026
Drop apache#6264 and apache#6837, which are fixed on main. Scope the datetime rebase
entry to the native Parquet scan and say the Delta scan rebases. Add the
ignored Parquet writer properties from apache#5304 and note that the row group
failure did not reproduce. Explain how apache/iceberg#17320 covers the
identity partition source case. Reword several entries, break the list of
Spark SQL tests into one bullet each, and note that spark.comet.scan.enabled
is a testing setting.
dwsmith1983 pushed a commit to dwsmith1983/datafusion-comet that referenced this pull request Oct 17, 2026
…it log (apache#7052)

* docs: list known Parquet and Iceberg correctness issues and add a data source audit log

Add a compatibility page that lists the open issues in which a Parquet or
Iceberg read or write gives a different result from Spark without falling
back, with links to the issues, and point to it from the compatibility
overview, the Parquet scan page, the Iceberg guide and the Iceberg writes
page. The datetime rebasing item moves from scans.md to the new page.

Correct the Iceberg writes page's claim that the float/double null-count
difference cannot change IS NULL pruning: iceberg-java's counts are the
wrong ones (apache/iceberg#17561).

Start a Data Source Audits section in the contributor guide, next to the
expression audits, recording the nested-type audits of the native Parquet
and Iceberg scans and writers.

* docs: add Parquet and Iceberg queries that fail where Spark succeeds

Add a section to the known issues page for the open issues in which a
Parquet or Iceberg read or write fails with an error where Spark, or
iceberg-java, succeeds: Parquet and Iceberg reads, object store and
credential settings that the native clients resolve differently from
Hadoop, and memory failures in the native writers.

The page becomes "Known Issues in Parquet and Iceberg Reads and Writes".
The existing sections move under "Results that differ from Spark" and keep
their heading anchors, and the links from the other pages use the new
title.

* docs: drop the Spark 4.1 NullType read limitation

parquet-rs accepts the UNKNOWN logical type on any physical type since
apache/arrow-rs#9855, which the parquet 59 crate includes, so reading a
Spark 4.1 Parquet file with a NullType column no longer fails (apache#4199).

* docs: address review feedback on the known issues page

Drop apache#6264 and apache#6837, which are fixed on main. Scope the datetime rebase
entry to the native Parquet scan and say the Delta scan rebases. Add the
ignored Parquet writer properties from apache#5304 and note that the row group
failure did not reproduce. Explain how apache/iceberg#17320 covers the
identity partition source case. Reword several entries, break the list of
Spark SQL tests into one bullet each, and note that spark.comet.scan.enabled
is a testing setting.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants