ORC: bind struct fields by Iceberg field id in generic and Spark 3.5 readers - #257
Conversation
…readers Backport the upstream id-based StructReader binding (apache/iceberg 77e7dbb, PR #15776) to the openhouse-1.5.2 ORC reader, adapted for the 1.5.x type system. Companion to the 1.2.x backport in linkedin/iceberg PR #256. The ORC StructReader binds fields positionally, consuming column vectors in projection order. This replaces that with id-based binding: each expected field is matched to its ORC column by Iceberg field id, resilient to field reordering and schema evolution. Only GenericOrcReaders and Spark 3.5 SparkOrcValueReaders are converted; the positional constructor/overloads are retained and marked @deprecated because other engine readers still use them. Row-lineage, UNKNOWN, and VARIANT constructs from the upstream commit are omitted as they do not exist on 1.5.x. Behavior-preserving for well-formed reads: id-based binding produces identical results to positional binding on all current inputs. Co-authored-by: Cursor <cursoragent@cursor.com>
Divergence vs
|
Reviewer orientationPlease review #256 (the 1.2.x backport) first. The ORC core of this PR is byte-identical to it, so the per-line backport / adapted / net-new classification lives there as inline review annotations: #256 (review) For this PR, the only genuinely new surface to review is the Spark 3.5 wiring ( Everything else (the shared ORC core + the equivalence test) is identical to #256: cbb330/iceberg-apache@compare/orc-id-binding-shared-core...compare/orc-id-binding-shared-core-1.5.x |
mkuchenbecker
left a comment
There was a problem hiding this comment.
Are we not going to achieve parity in parquet? What guards do we have enabling this and are all the tables using orc today?
| this.isConstantOrMetadataField[pos] = true; | ||
| this.readers[pos] = constants(false); | ||
| } else if (MetadataColumns.isMetadataColumn(field.name())) { | ||
| // in case of any other metadata field, fill with nulls |
There was a problem hiding this comment.
what is the impact on nullable? Bu defautl these mist be nullable, no? Otherwise it will not work with Trino at-all
There was a problem hiding this comment.
does Trino use the same orc reader logic via import?
There was a problem hiding this comment.
it uses its own readers https://github.com/linkedin-grid/trino/blob/7d4e7662df62e2a3d63d47bea76d012b1d666ac9/plugin/trino-iceberg/src/main/java/io/trino/plugin/iceberg/IcebergPageSourceProvider.java#L820, it supports id based mapping
There was a problem hiding this comment.
^ this
I think trino doesn't use any of the code changed in this diff. but I will do some investigation and potentially put up an acc test or do a smoke test in li-oh to confirm that trino doesn't use the apache/iceberg/orc code at runtime.
this goes into the architecture of trino vs other engines. trino manages its own record input/output implementations which must cover all datasources while engines like spark delegate IO to pluggable data sources using DSv2
| List<String> names, | ||
| List<OrcValueReader<?>> fields) { | ||
| return SparkOrcValueReaders.struct(fields, expected, idToConstant); | ||
| return SparkOrcValueReaders.struct(record, fields, expected, idToConstant); |
There was a problem hiding this comment.
So TLDR this adds the schema to align orc columns to ORC columns
Port the test-only follow-up commit from #265 (openhouse-1.2.0) so this 1.5.2 backport is a 1:1 match: - TestGenericOrcReaderIdBinding.testNonIdentityFieldIdMapping: forces expected fields to map to different ORC vector positions. - TestGenericOrcReaderIdBinding.testIdLessFileBindsThroughNameMapping: an id-less (Hive-migrated) ORC file still binds through name mapping, and a field absent from it reads null. - TestSparkOrcReader.nonIdentityFieldIdMapping: same non-identity vector mapping through the Spark 3.5 reader, written in the JUnit 5 / AssertJ style used on this branch. Generated-by: pi (GPT-5.6)
teamurko
left a comment
There was a problem hiding this comment.
Confirming that it's the same change as #265
Overall, LGTM, letting @mkuchenbecker to review
good question @mkuchenbecker, I could add the answer on this PR: #263 since there are a lot of open PRs and questions. this PR is only a backport of upstream/main of the underlying properties that default values would need to layer onto. |
Summary
Backports the upstream id-based ORC
StructReaderbinding (apache/iceberg77e7dbb24, PR #15776) to theopenhouse-1.5.2line. This is the 1.5.x companion to the 1.2.x backport in #256, targeting Spark 3.5.What changes: the ORC reader used to bind struct fields positionally — consuming ORC column vectors in projection order, assuming projected field order matches the file's physical column order. This PR switches the converted readers to id-based binding: each expected field is matched to its ORC column by its Iceberg field id, robust to field reordering and schema evolution.
Why it's safe:
buildOrcProjectionalready normalizes column order, so id-based binding produces identical results to positional binding on all current inputs. It is behavior-preserving — and the prerequisite for the follow-up work (see Next steps).Review this after #256 — it is nearly the same change
The ORC core of this PR is byte-identical to #256. Proof (renders "the branches are identical"): cbb330/iceberg-apache@compare/orc-id-binding-shared-core...compare/orc-id-binding-shared-core-1.5.x
So the only thing that genuinely needs fresh review here is the Spark 3.5 wiring. Everything else mirrors #256 (whose inline annotations classify each change).
How id-based binding works (quick primer)
An ORC file stores columns in a physical order. Iceberg has an expected schema, and every field has a permanent numeric field id. Positional binding = "the Nth expected field is the Nth file column" (trusts order); id-based binding = "match each expected field to the file column with the same field id" (order-independent). The real logic lives in the shared
OrcValueReaders.StructReaderbase class; the per-engine reader edits just thread the ORC file schema (TypeDescription) down to it and opt in.Provenance of each change
OrcValueReaders,GenericOrcReaders,GenericOrcReaderSparkOrcValueReaders/SparkOrcReader(v3.5)@Deprecatedpositional overload upstream deleted. NotablySparkOrcReader's one-liner is exactly what upstreammainalready ships for Spark 3.5 (see divergence comment).TestGenericOrcReaderIdBindingTestSparkOrcReadMetadataColumns/TestGenericReadProjectionAdaptations vs
apache/main1.5.x lacks the constructs the upstream commit references, so these are intentionally omitted (they would not compile on 1.5.x):
ROW_ID/LAST_UPDATED_SEQUENCE_NUMBERbranches,handleRowIdField/handleLastUpdatedSeqField,RowIdReader/LastUpdatedSeqReader.UNKNOWNtype clause in the metadata fallback.VARIANTreader/visitor.Everything else is aligned to upstream verbatim.
Scope
Only
GenericOrcReadersand Spark 3.5SparkOrcValueReadersare converted. The positional constructor/overloads are retained and@Deprecatedbecause the unconverted readers (Spark 3.3/3.4, Flink) still call them.Reviewing the divergence from upstream
77e7dbb24apache/main: apache/iceberg@main...cbb330:iceberg-apache:compare/orc-id-binding-1.5.x-vs-apache-main (hit Retry if it says "taking too long to generate")apache/main" comment on this PR.Testing Done
Validated on JDK 11:
./gradlew :iceberg-data:test --tests TestGenericOrcReaderIdBinding— pass (type promotion, reordered projection,_pos/_deleted,idToConstant)../gradlew -DsparkVersions=3.5 -DscalaVersion=2.12 :iceberg-spark:iceberg-spark-3.5_2.12:test(incl.TestSparkOrcReader,TestSparkOrcReadMetadataColumns) — pass../gradlew :iceberg-orc:revapi— pass; no.palantir/revapi.ymlchange needed.spotlessApplyclean;iceberg-orc/iceberg-data/iceberg-spark-3.5_2.12compile.Next steps (this is step 1 of 2)
This PR lands only the binding mechanism and does not change read output. The motivating follow-up is column default values: once fields are bound by id, a subsequent change can use those bound ids to resolve and apply an Iceberg field's initial-default value for fields present in the expected schema but absent from the ORC file (which today read as
null). Defaults are only correct once binding is keyed on field id rather than position, so this PR is the prerequisite. Defaults will land as a separate PR on top of this one.