Write Iceberg optional lists, maps and structs as OPTIONAL in the Parquet footer - #112669
Conversation
An Iceberg field declared "required": false whose type is a complex container (list/map/struct) was serialized to Parquet with FieldRepetitionType::REQUIRED, so the footer contradicted the Iceberg schema published by the same commit. Spec-compliant external readers (Spark, Trino, pyiceberg) reject that. The optionality is not recoverable from the ClickHouse type: DataTypeArray and DataTypeMap both report canBeInsideNullable() == false, and the Iceberg schema builder returns a bare container while applying makeNullable only to leaf scalars. So prepareColumnArray, prepareColumnTuple and prepareColumnMap each hard-coded their group REQUIRED, and the only producer of OPTIONAL, prepareColumnNullable, is unreachable for them. ColumnMapper already carries the recovered bit as iceberg_optional_paths and the ORC writer already consumes it; the Parquet writer threaded only the field-id map. This cannot be a schema-only flip. The reader derives its max definition level purely from the schema while the writer derives the RLE level bit width from the state the data path builds, so raising the schema's OPTIONAL count alone desynchronizes them and the file becomes structurally unreadable. Both paths are now given the same IcebergOptionality: the schema path through convertSchema, and both the serial and the parallel encoder path in ParquetBlockOutputFormat. When a container's dotted Iceberg path is marked optional the group becomes OPTIONAL and one definition level is added below it, strictly after updateRepDefLevelsForArray: that function encodes an empty container as level 0, and level 0 under an OPTIONAL ancestor means "null container" to a reader, so incrementing afterwards shifts empty-but-present to 1 and leaves 0 free. A Nullable that already supplies the OPTIONAL level for the same path suppresses the extra one, since Nullable is transparent in Iceberg field naming. With no mapper, or a mapper carrying only field ids, the writer keeps its current behavior, which Iceberg position-delete writes rely on. The new stateless test asserts per-leaf definition levels through pyarrow across both encoders, with a required-container half as the control. pyarrow is the oracle rather than a ClickHouse round-trip because ClickHouse's reader normalizes a null container to an empty one, so a round-trip cannot see the ordering error the increment position guards against. Map keys, every repeated level and optional scalars are asserted unchanged and the values round-trip identically, so this only corrects metadata. No new or changed setting, so no SettingsChangesHistory.cpp entry. Files written by older versions keep their REQUIRED groups and stay readable. Related: ClickHouse#111775 Related: ClickHouse#109994 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…test A leaf's max definition level counts every nullable ancestor along its whole chain, so it cannot say WHICH ancestor contributed it. Moving the OPTIONAL from a container group down onto a node beneath it leaves both of the probe's observables byte-identical, which means the reference could not distinguish the intended footer from a plausible mis-implementation. Which node carries the OPTIONAL is the property an external Iceberg reader validates against the published schema, so it is exactly what the test has to pin. Add a per-node oracle built on pyarrow's typed schema_arrow field nullability, covering the nodes the change claims to flip (arr, m, st, nst.element, nq.inner) and the ones it claims not to (the map key, which is always required per the Iceberg spec, nst itself, and the optional scalar). The existing per-leaf level block and the decoded values stay untouched: schema_arrow collapses the repeated list wrapper, so it reports neither the repetition levels nor the present-but-empty versus null distinction. Emit the probes from a fixed list rather than by iterating a mapping so the reference cannot churn on iteration order, and prefer schema_arrow over the raw footer repr, which embeds field ids and is an unversioned pretty-printer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ader claims The test header claimed spec-compliant external readers (Spark, Trino, pyiceberg) reject a REQUIRED group where the Iceberg schema says optional. That is unverified for Spark and Trino, and false for pyiceberg: Iceberg documents makeColumnOptional as supported schema evolution, so the lenient direction our old footer sat in is accepted by the reference implementation's own schema-compatibility validator. Replace it with the harm that is actually measurable. A REQUIRED group cannot encode a null container at all, so writing a null list, map or struct silently read it back as an empty one; with the group marked OPTIONAL the null survives. Verified on pyarrow 22.0.0 and 24.0.0 for all three container kinds. That is a better reason for the change than a rejection that cannot be demonstrated in this job, whose image pins pyarrow only. The probe commentary said which node carries the OPTIONAL is what an external reader "validates"; softened to what has to match the published schema, for the same reason. Comments only. No source, no reference, no behaviour change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header claimed a REQUIRED group cannot encode a null container, so a null list/map/struct was silently read back as an empty one. That harm is not what this change fixes, and the reference shipped in the same commit refutes it. Every node this fix newly flips to OPTIONAL is a type that cannot hold a ClickHouse null: Nullable(Array(Int32)) and Nullable(Map(String,Int32)) are rejected at type construction because canBeInsideNullable() is false, and st, nst.element and nq.inner are not declared Nullable. The one nullable container, nq, was already emitted as an optional group by prepareColumnNullable before this change; the fix flips nq.inner. The test does insert NULL into ns and nq, both are optional groups in half A and half B, and their null row reads back as a struct of defaults in both halves on both encoders, while the scalar control sc reads back NULL. The header now states only what the node lines measure: the footer contradicting the Iceberg schema published by the same commit. Comment-only. No source, reference or behaviour change.
Internal second-model review: adjudication log (click to expand)Pre-publication review by an independent model, run cold against the resulting code before the
Severity: ❌ blocker / Independent verification I performed before adjudicating, recorded because it is what the verdicts Three points I checked hardest. The dotted-path root is identical across all three call sites Test liveness was traced rather than asserted. Against a pristine build the reference differs by 64 Session id: cron:clickhouse-review-slot-10:20260730-210300 |
Pre-PR validation gate (a-i)
Regression sweep: Parquet + Iceberg stateless selection, identical selection and config on both Session id: cron:clickhouse-review-slot-10:20260730-210300 |
|
cc @PedroTadim @vitlibar, could you review this? It is the Parquet half of the same optional-complex defect you merged for ORC in #111775: an Iceberg |
|
Workflow [PR], commit [b7d3613] Summary: ❌
AI ReviewSummaryThis PR threads Iceberg per-path optionality into Parquet schema generation and both encoder paths so optional list, map, and struct groups are emitted as Final Verdict
|
LLVM Coverage Report
Changed lines: Changed C/C++ lines covered: 129/130 (99.23%) · Uncovered code |
CI finish ledger — b7d3613Every failure below has an owner: a fixing PR (mine or external), or a full-effort fix CI is fully finished on this head: 174 check-runs, 0 queued or in progress,
Neither is caused by this PR, whose diff is confined to the Iceberg Parquet writer The two coverage rows are one event: the The upgrade-check row is the fleet-wide error-log leak on that job: 34 rows across 34 distinct Session id: cron:our-pr-ci-monitor:20260731-040000 |
e61186d
Related: #111775
Related: #109994
Changelog category (leave one):
Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):
Description
An Iceberg field declared
"required": falsewhose type is a container was written to Parquet asREQUIRED, so the footer contradicted the Iceberg schema published by the same commit. Theoptionality is not recoverable from the ClickHouse type:
ArrayandMapare never wrapped inNullable(canBeInsideNullable()is false for both) and the Iceberg schema builder returns abare container, so
prepareColumnArray/prepareColumnTuple/prepareColumnMapeachhard-coded their group
REQUIRED.ColumnMapperalready carries the recovered bit(
iceberg_optional_paths) and the ORC writer already consumes it; the Parquet writer threaded onlythe field-id map.
This is deliberately not a schema-only flip: the reader takes its max definition level from the
schema while the writer takes the level bit width from the state built on the data path, so the two
have to move together or the file becomes unreadable. Both now receive the same
IcebergOptionality(
convertSchema, plus the serial and parallel encoder paths). When a container's dotted Iceberg pathis marked optional, the group becomes
OPTIONALand one definition level is added below it, strictlyafter
updateRepDefLevelsForArrayso a present-but-empty container keeps level 1 and level 0 staysfree to mean "null container". With no mapper, or a mapper carrying only field ids, the writer keeps
its current behaviour, which is what Iceberg position-delete writes rely on.
Validation: a new stateless test asserts, through pyarrow across both encoders, the per-leaf
definition levels and which schema node carries the OPTIONAL, with a required-container half as the
control. Without the fix its reference differs by 64 lines. Map keys, every
repeatedlevel andoptional scalars are asserted unchanged, and values round-trip identically, so this only corrects
metadata. No new or changed setting, so no
SettingsChangesHistory.cppentry; files written byolder versions keep their
REQUIREDgroups and stay readable.Version info
26.8.1.491(included in26.8and later)