Skip to content

ORC: bind struct fields by Iceberg field id in generic and Spark 3.1 readers - #256

Closed
cbb330 wants to merge 1 commit into
1.2.xfrom
chbush/orc-id-binding-backport
Closed

ORC: bind struct fields by Iceberg field id in generic and Spark 3.1 readers#256
cbb330 wants to merge 1 commit into
1.2.xfrom
chbush/orc-id-binding-backport

Conversation

@cbb330

@cbb330 cbb330 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Backports the upstream id-based ORC StructReader binding (apache/iceberg 77e7dbb24, PR #15776) to the li-1.2.x line, adapted for the 1.2.x type system.

What changes: the ORC reader used to bind struct fields positionally — it consumed ORC column vectors in projection order, assuming the 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, which is robust to field reordering and schema evolution.

Why it's safe: for the files this fork reads today, buildOrcProjection already normalizes column order, so id-based binding produces identical results to positional binding on all current inputs. It is a behavior-preserving robustness change — and the prerequisite for the follow-up work (see Next steps).

How id-based binding works (quick primer)

An ORC file stores columns in a physical order. When Iceberg reads it, it has an expected schema (the fields it wants), and every Iceberg field has a permanent numeric field id. Two ways to match expected fields to file columns:

  • Positional (old): "the Nth expected field comes from the Nth file column" — trusts order.
  • Id-based (new): "for each expected field, find the file column carrying the same field id" — order-independent.

The real logic lives in the shared OrcValueReaders.StructReader base class (in iceberg-orc); the per-engine reader edits are thin plumbing that thread the ORC file schema (TypeDescription) down to it and opt in.

Provenance of each change

Change Classification
OrcValueReaders: id-binding constructor + readersByFieldId / buildFieldIdToOrcIndex + readInternal selection Backported ~verbatim from upstream (minus the omitted branches below)
GenericOrcReaders 4-arg struct + subclass ctor; GenericOrcReader record threading Backported exactly (identical one-line call sites)
SparkOrcValueReaders / SparkOrcReader (v3.1) Backported with adaptation — same shape; retains the @Deprecated positional overload that upstream deleted
TestGenericOrcReaderIdBinding Net new, modeled on the shipped TestSparkOrcReadMetadataColumns / TestGenericReadProjection

Per-line classification is in the inline review annotations on this PR.

Adaptations vs apache/main (why it diverges at all)

1.2.x lacks constructs the upstream commit references, so these are intentionally omitted (porting them verbatim would not compile on 1.2.x):

  1. Row-lineage — the ROW_ID / LAST_UPDATED_SEQUENCE_NUMBER branches, the handleRowIdField / handleLastUpdatedSeqField helpers, and the RowIdReader / LastUpdatedSeqReader classes.
  2. UNKNOWN type — the || field.type().typeId() == Type.TypeID.UNKNOWN clause in the metadata fallback.
  3. VARIANT — the variant reader/visitor.

Everything else is aligned to upstream verbatim (method ordering, Javadoc, whitespace, no defensive null-guards).

Scope

Only GenericOrcReaders and Spark 3.1 SparkOrcValueReaders are converted. The positional constructor/overloads are retained and @Deprecated because the unconverted readers (Spark 2.4/3.2/3.3, Flink 1.14/1.15/1.16) still call them — those readers keep identical positional behavior.

Reviewing the divergence from upstream

  • Upstream source: PR #15776 / commit 77e7dbb24
  • Interactive compare of this backport vs apache/main: apache/iceberg@main...cbb330:iceberg-apache:compare/orc-id-binding-vs-apache-main (if GitHub says "taking too long to generate," hit Retry — a large-repo cross-fork rendering quirk)
  • Embedded, always-renders copy of that diff: see the pinned "Divergence vs apache/main" comment on this PR.
  • Reproduce locally: git fetch apache main && git diff apache/main -- orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java

Companion PR

The identical change is backported to the 1.5.x line (targeting Spark 3.5) in #257. The ORC core is byte-identical between the two backports — proof (renders "the branches are identical"): cbb330/iceberg-apache@compare/orc-id-binding-shared-core...compare/orc-id-binding-shared-core-1.5.x

Testing Done

  • Unit tests added/updated
  • Integration tests pass
  • Manual testing performed

Validated on JDK 11:

  • ./gradlew :iceberg-orc:test :iceberg-data:test — pass. New TestGenericOrcReaderIdBinding covers type promotion (int→long, float→double, decimal widening), reordered projection, _pos / _deleted metadata columns, and idToConstant.
  • ./gradlew -DsparkVersions=3.1 -DscalaVersion=2.12 :iceberg-spark:iceberg-spark-3.1_2.12:test — pass (incl. TestSparkOrcReadMetadataColumns, whose non-vectorized path exercises the new id-binding constructor's metadata branches).
  • ./gradlew :iceberg-orc:revapi — pass. The public additions do not trip RevAPI, so no .palantir/revapi.yml change is needed.
  • spotlessApply clean; the untouched engines (Flink 1.14/1.15/1.16, Spark 3.2/3.3) still compile against the retained deprecated positional path.

Next steps (this is step 1 of 2)

This PR lands only the binding mechanism and deliberately 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 that are present in the expected schema but absent from the ORC file (which today read as null). Applying defaults correctly is only possible once binding is keyed on field id rather than position — so this PR is the prerequisite. The defaults work will land as a separate PR on top of this one.

@cbb330 cbb330 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Provenance annotations for each code block, categorized as: backported exactly / backported with adaptations / net new / inspiration from shipped code. Reference: apache/iceberg 77e7dbb24 (PR #15776).

Comment on lines +140 to +144
// Maps each expected field position to the ORC column index it should read from. Only used by
// the id-based binding constructor; left null by the deprecated positional constructor so that
// {@link #readInternal} preserves the exact positional behavior for readers that have not been
// converted to id-based binding.
private final int[] orcFieldIndex;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported with adaptations. The orcFieldIndex field is from upstream's StructReader. The doc-comment explaining the null sentinel is reworded for this fork to make explicit that positional binding is preserved for unconverted engine readers.

Comment on lines +146 to +158
/**
* @deprecated since 1.2.1, positional binding assumes the projected field order matches the
* file's physical column order. Use {@link #StructReader(TypeDescription, List,
* Types.StructType, Map)} for id-based binding instead. Kept for engine readers that have
* not yet migrated.
*/
@Deprecated
protected StructReader(
List<OrcValueReader<?>> readers, Types.StructType struct, Map<Integer, ?> idToConstant) {
List<Types.NestedField> fields = struct.fields();
this.readers = new OrcValueReader[fields.size()];
this.isConstantOrMetadataField = new boolean[fields.size()];
this.orcFieldIndex = null;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported with adaptations. Upstream keeps the positional constructor @Deprecated too; we mirror that (other engines still call it). Adaptation: this.orcFieldIndex = null; is the sentinel that makes readInternal fall back to exact positional behavior.

Comment on lines +180 to +219
protected StructReader(
TypeDescription orcType,
List<OrcValueReader<?>> readers,
Types.StructType struct,
Map<Integer, ?> idToConstant) {
List<Types.NestedField> fields = struct.fields();
this.readers = new OrcValueReader[fields.size()];
this.isConstantOrMetadataField = new boolean[fields.size()];
this.orcFieldIndex = new int[fields.size()];

Map<Integer, OrcValueReader<?>> readersById = readersByFieldId(orcType, readers);
Map<Integer, Integer> fieldIdToOrcIndex = buildFieldIdToOrcIndex(orcType);

for (int pos = 0; pos < fields.size(); pos += 1) {
Types.NestedField field = fields.get(pos);
OrcValueReader<?> fileReader = readersById.get(field.fieldId());

if (idToConstant.containsKey(field.fieldId())) {
this.isConstantOrMetadataField[pos] = true;
this.readers[pos] = constants(idToConstant.get(field.fieldId()));
} else if (field.equals(MetadataColumns.ROW_POSITION)) {
this.isConstantOrMetadataField[pos] = true;
this.readers[pos] = new RowPositionReader();
} else if (field.equals(MetadataColumns.IS_DELETED)) {
this.isConstantOrMetadataField[pos] = true;
this.readers[pos] = constants(false);
} else if (fileReader != null) {
this.isConstantOrMetadataField[pos] = false;
this.orcFieldIndex[pos] = fieldIdToOrcIndex.getOrDefault(field.fieldId(), -1);
this.readers[pos] = fileReader;
} else if (MetadataColumns.isMetadataColumn(field.name())) {
// in case of any other metadata field, fill with nulls
this.isConstantOrMetadataField[pos] = true;
this.readers[pos] = constants(null);
} else {
throw new IllegalArgumentException(
String.format("Missing ORC reader for field %s (%s)", field.name(), field.fieldId()));
}
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported exactly (minus deliberately omitted branches). This id-binding constructor matches the upstream StructReader line-for-line, except the row-lineage branches (ROW_ID / LAST_UPDATED_SEQUENCE_NUMBER + their handle* helpers) and the || field.type().typeId() == Type.TypeID.UNKNOWN clause are dropped — neither construct exists on 1.2.x.

Comment on lines +221 to +243
private Map<Integer, Integer> buildFieldIdToOrcIndex(TypeDescription orcType) {
List<TypeDescription> children = orcType.getChildren();
Map<Integer, Integer> mapping = Maps.newHashMap();
for (int i = 0; i < children.size(); i++) {
mapping.put(ORCSchemaUtil.fieldId(children.get(i)), i);
}
return mapping;
}

private Map<Integer, OrcValueReader<?>> readersByFieldId(
TypeDescription orcType, List<OrcValueReader<?>> readerList) {
List<TypeDescription> children = orcType.getChildren();
Preconditions.checkState(
children.size() == readerList.size(),
"Invalid ORC reader binding: children=%s readers=%s",
children.size(),
readerList.size());
Map<Integer, OrcValueReader<?>> readersById = Maps.newHashMap();
for (int i = 0; i < children.size(); i += 1) {
readersById.put(ORCSchemaUtil.fieldId(children.get(i)), readerList.get(i));
}
return readersById;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported exactly. buildFieldIdToOrcIndex and readersByFieldId are verbatim from upstream (including the Preconditions.checkState binding invariant).

Comment on lines 259 to +268
private T readInternal(T struct, ColumnVector[] columnVectors, int row) {
for (int c = 0, vectorIndex = 0; c < readers.length; ++c) {
int vectorIndex = 0;
for (int c = 0; c < readers.length; ++c) {
ColumnVector vector;
if (isConstantOrMetadataField[c]) {
vector = null;
} else if (orcFieldIndex != null) {
vector = columnVectors[orcFieldIndex[c]];
} else {
vector = columnVectors[vectorIndex];
vectorIndex++;
vector = columnVectors[vectorIndex++];

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported exactly. readInternal's three-way index selection (constant/metadata → null, id-binding → orcFieldIndex[c], positional → vectorIndex++) is identical to upstream.

List<String> names,
List<OrcValueReader<?>> fields) {
return GenericOrcReaders.struct(fields, expected, idToConstant);
return GenericOrcReaders.struct(record, fields, expected, idToConstant);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported exactly. Visitor call site now passes the record TypeDescription — identical one-line change to upstream (record was already provided by the visitor).

Comment on lines +67 to +73
static OrcValueReader<?> struct(
TypeDescription orcType,
List<OrcValueReader<?>> readers,
Types.StructType struct,
Map<Integer, ?> idToConstant) {
return new StructReader(orcType, readers, struct, idToConstant);
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported with adaptations. Mirrors upstream's 4-arg struct. Adaptation: upstream (Spark v3.5+) deletes the positional overload; here it is retained just below as @Deprecated because Spark 2.4/3.2/3.3 and Flink still use it on 1.2.x.

Comment on lines +153 to +160
protected StructReader(
TypeDescription orcType,
List<OrcValueReader<?>> readers,
Types.StructType struct,
Map<Integer, ?> idToConstant) {
super(orcType, readers, struct, idToConstant);
this.numFields = struct.fields().size();
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported with adaptations. 4-arg subclass constructor mirroring upstream; the positional subclass constructor is retained @Deprecated (see below).

List<String> names,
List<OrcValueReader<?>> fields) {
return SparkOrcValueReaders.struct(fields, expected, idToConstant);
return SparkOrcValueReaders.struct(record, fields, expected, idToConstant);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Backported exactly. Visitor call site passes record — identical to upstream, applied to the 3.1 file.

* exercise branches of the id-binding {@code StructReader} constructor that the shared projection
* tests do not, and assert that id-binding produces the same results positional binding would.
*/
public class TestGenericOrcReaderIdBinding {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Net new, using inspiration from shipped tests. Not part of the upstream commit. The metadata-column approach (project without metadata fields, hand the reader the full schema incl. _pos/_deleted) is modeled on the shipped spark/v3.1/.../TestSparkOrcReadMetadataColumns.java; the write/project/read harness follows TestGenericReadProjection / TestGenericData. Covers type promotion, reordered projection, metadata columns, and idToConstant.

@cbb330

cbb330 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Comparison against apache/iceberg main

A native GitHub apache/main...this-branch compare URL isn't available: linkedin/iceberg and apache/iceberg aren't in the same fork network (the compare API returns 404), and such a compare would be dominated by unrelated fork/version divergence anyway. Here are the focused references instead:

The upstream change this backports (canonical diff):

apache/main versions of the shared-path files (near-identical to this PR's versions; the only deltas are the deliberately-omitted row-lineage/UNKNOWN/VARIANT branches and the retained @Deprecated positional path — see the provenance annotations above):

To reproduce the exact file-level diff locally:

git fetch --depth=1 apache main
git diff apache/main -- orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java
git diff apache/main -- orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java
git diff apache/main -- orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java

@cbb330

cbb330 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Noise-free compare vs apache/iceberg main

Native compare URL (only the backport-relevant files; all other files are byte-identical to apache/main and drop out):

apache/iceberg@main...cbb330:iceberg-apache:compare/orc-id-binding-vs-apache-main

How the "thousands of unrelated fork/version diffs" were removed: I created a throwaway branch on a fork of apache/iceberg (cbb330/iceberg-apache) that is apache/main with only the 6 files from this PR overlaid on top (built server-side via the Git Data API, parent = current apache/main). So the three-dot compare surfaces exactly the backport delta.

Reading guide (6 files, behind_by: 0):

  • orc/.../OrcValueReaders.java, GenericOrcReaders.java, GenericOrcReader.java — shown as modified. Since these paths exist on apache/main, the diff is a true head-to-head against upstream. The "deletions" are upstream-only code intentionally not backported (row-lineage readers/branches, VARIANT, the UNKNOWN clause, the unguarded template init) — i.e. exactly the adaptations called out in the inline annotations.
  • spark/v3.1/.../SparkOrcValueReaders.java, SparkOrcReader.java — shown as added because apache/main no longer ships a spark/v3.1 module (it's on 3.5/4.0/4.1). Compare against spark/v3.5/.../SparkOrcValueReaders.java on main for the equivalent upstream shape.
  • data/.../TestGenericOrcReaderIdBinding.javaadded (net-new test).

Note: compare/orc-id-binding-vs-apache-main is a throwaway comparison artifact, not intended for merge.

@cbb330

cbb330 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

The work itself (additions), as a clean compare

The apache/main compare above frames our code as a subset of upstream (so our changes show as deletions of the lineage/VARIANT code we omit). To see exactly the work this PR does — the positional → id-based binding conversion, as green additions — here is a stacked, noise-free compare:

cbb330/iceberg-apache@orc-id-binding-baseline...orc-id-binding-work

It is two throwaway branches on a fork of apache/iceberg:

  • orc-id-binding-baseline = apache/main with the pre-backport li-1.2.x versions of the 5 touched source files overlaid.
  • orc-id-binding-work = that baseline + this PR's backport on top.

So the three-dot diff is precisely the backport delta, as additions (identical to this PR's own Files-changed view, just isolated to the 6 files and hosted where it can sit next to apache/main):

  • OrcValueReaders.java +87 / −4 — id-binding constructor, readersByFieldId / buildFieldIdToOrcIndex, readInternal selection, @Deprecated positional ctor
  • GenericOrcReaders.java +28, SparkOrcValueReaders.java +28 — 4-arg struct + subclass ctor
  • GenericOrcReader.java +1/−1, SparkOrcReader.java +1/−1 — visitor call sites pass record
  • TestGenericOrcReaderIdBinding.java +220 — net-new equivalence tests

(The equivalent view with zero extra branches is simply this PR's Files changed tab.) All orc-id-binding-* branches on cbb330/iceberg-apache are disposable comparison artifacts, not for merge.

@cbb330

cbb330 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Updated for upstream fidelity in d5ba3876d:

@cbb330

cbb330 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Divergence vs apache/main (embedded)

Full git diff apache/main -- <file> for the shared ORC files, as of apache/main 470f4642bdda. This is the always-renders copy of the interactive compare. Deletions here are the intentionally-omitted upstream constructs (row-lineage, UNKNOWN, VARIANT) that do not exist on 1.2.x; the id-binding logic itself is verbatim.

orc/.../orc/OrcValueReaders.java vs apache/main
diff --git a/orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java b/orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java
index c1fba3f15..e13689eb2 100644
--- a/orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java
+++ b/orc/src/main/java/org/apache/iceberg/orc/OrcValueReaders.java
@@ -24,7 +24,6 @@ import java.util.Map;
 import org.apache.iceberg.MetadataColumns;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.Maps;
-import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.Types;
 import org.apache.orc.TypeDescription;
 import org.apache.orc.storage.ql.exec.vector.BytesColumnVector;
@@ -149,7 +148,7 @@ public class OrcValueReaders {
      * @param idToConstant constant values by field id
      * @deprecated Use {@link #StructReader(TypeDescription, List, Types.StructType, Map)} instead.
      *     This constructor uses position-based binding which may cause field misalignment in MOR
-     *     scenarios. This doesn't work lineage scenarios.
+     *     scenarios.
      */
     @Deprecated
     protected StructReader(
@@ -170,8 +169,7 @@ public class OrcValueReaders {
         } else if (field.equals(MetadataColumns.IS_DELETED)) {
           this.isConstantOrMetadataField[pos] = true;
           this.readers[pos] = constants(false);
-        } else if (MetadataColumns.isMetadataColumn(field.name())
-            || field.type().typeId() == Type.TypeID.UNKNOWN) {
+        } else if (MetadataColumns.isMetadataColumn(field.name())) {
           this.isConstantOrMetadataField[pos] = true;
           this.readers[pos] = constants(null);
         } else {
@@ -196,13 +194,8 @@ public class OrcValueReaders {
       for (int pos = 0; pos < fields.size(); pos += 1) {
         Types.NestedField field = fields.get(pos);
         OrcValueReader<?> fileReader = readersById.get(field.fieldId());
-        int orcIndex = fieldIdToOrcIndex.getOrDefault(field.fieldId(), -1);
 
-        if (field.equals(MetadataColumns.ROW_ID)) {
-          handleRowIdField(pos, field, fileReader, idToConstant, orcIndex);
-        } else if (field.equals(MetadataColumns.LAST_UPDATED_SEQUENCE_NUMBER)) {
-          handleLastUpdatedSeqField(pos, field, fileReader, idToConstant, orcIndex);
-        } else if (idToConstant.containsKey(field.fieldId())) {
+        if (idToConstant.containsKey(field.fieldId())) {
           this.isConstantOrMetadataField[pos] = true;
           this.readers[pos] = constants(idToConstant.get(field.fieldId()));
         } else if (field.equals(MetadataColumns.ROW_POSITION)) {
@@ -215,8 +208,7 @@ public class OrcValueReaders {
           this.isConstantOrMetadataField[pos] = false;
           this.orcFieldIndex[pos] = fieldIdToOrcIndex.getOrDefault(field.fieldId(), -1);
           this.readers[pos] = fileReader;
-        } else if (MetadataColumns.isMetadataColumn(field.name())
-            || field.type().typeId() == Type.TypeID.UNKNOWN) {
+        } else if (MetadataColumns.isMetadataColumn(field.name())) {
           this.isConstantOrMetadataField[pos] = true;
           this.readers[pos] = constants(null);
         } else {
@@ -253,49 +245,6 @@ public class OrcValueReaders {
       return readersById;
     }
 
-    @SuppressWarnings("unchecked")
-    private void handleRowIdField(
-        int pos,
-        Types.NestedField field,
-        OrcValueReader<?> fileReader,
-        Map<Integer, ?> idToConstant,
-        int orcIndex) {
-      Long firstRowId = (Long) idToConstant.get(field.fieldId());
-      if (firstRowId != null) {
-        OrcValueReader<Long> fileIdReader = (OrcValueReader<Long>) fileReader;
-        this.readers[pos] = new RowIdReader(firstRowId, fileIdReader);
-        this.isConstantOrMetadataField[pos] = fileIdReader == null;
-        if (fileIdReader != null) {
-          this.orcFieldIndex[pos] = orcIndex;
-        }
-      } else {
-        this.isConstantOrMetadataField[pos] = true;
-        this.readers[pos] = constants(null);
-      }
-    }
-
-    @SuppressWarnings("unchecked")
-    private void handleLastUpdatedSeqField(
-        int pos,
-        Types.NestedField field,
-        OrcValueReader<?> fileReader,
-        Map<Integer, ?> idToConstant,
-        int orcIndex) {
-      Long fileLastUpdated = (Long) idToConstant.get(field.fieldId());
-      Long firstRowId = (Long) idToConstant.get(MetadataColumns.ROW_ID.fieldId());
-      if (fileLastUpdated != null && firstRowId != null) {
-        OrcValueReader<Long> fileSeqReader = (OrcValueReader<Long>) fileReader;
-        this.readers[pos] = new LastUpdatedSeqReader(fileLastUpdated, fileSeqReader);
-        this.isConstantOrMetadataField[pos] = fileSeqReader == null;
-        if (fileSeqReader != null) {
-          this.orcFieldIndex[pos] = orcIndex;
-        }
-      } else {
-        this.isConstantOrMetadataField[pos] = true;
-        this.readers[pos] = constants(null);
-      }
-    }
-
     protected abstract T create();
 
     protected abstract void set(T struct, int pos, Object value);
@@ -371,76 +320,4 @@ public class OrcValueReaders {
       this.batchOffsetInFile = newBatchOffsetInFile;
     }
   }
-
-  private static class RowIdReader implements OrcValueReader<Long> {
-    private final long firstRowId;
-    private final OrcValueReader<Long> fileIdReader;
-    private final RowPositionReader posReader;
-
-    RowIdReader(long firstRowId, OrcValueReader<Long> fileIdReader) {
-      this.firstRowId = firstRowId;
-      this.fileIdReader = fileIdReader;
-      this.posReader = new RowPositionReader();
-    }
-
-    @Override
-    public Long read(ColumnVector vector, int row) {
-      if (fileIdReader != null) {
-        Long idFromFile = fileIdReader.read(vector, row);
-        if (idFromFile != null) {
-          return idFromFile;
-        }
-      }
-
-      long pos = posReader.read(null, row);
-      return firstRowId + pos;
-    }
-
-    @Override
-    public Long nonNullRead(ColumnVector vector, int row) {
-      return read(vector, row);
-    }
-
-    @Override
-    public void setBatchContext(long batchOffsetInFile) {
-      posReader.setBatchContext(batchOffsetInFile);
-      if (fileIdReader != null) {
-        fileIdReader.setBatchContext(batchOffsetInFile);
-      }
-    }
-  }
-
-  private static class LastUpdatedSeqReader implements OrcValueReader<Long> {
-    private final long fileLastUpdated;
-    private final OrcValueReader<Long> fileSeqReader;
-
-    LastUpdatedSeqReader(long fileLastUpdated, OrcValueReader<Long> fileSeqReader) {
-      this.fileLastUpdated = fileLastUpdated;
-      this.fileSeqReader = fileSeqReader;
-    }
-
-    @Override
-    public Long read(ColumnVector vector, int row) {
-      if (fileSeqReader != null) {
-        Long seqFromFile = fileSeqReader.read(vector, row);
-        if (seqFromFile != null) {
-          return seqFromFile;
-        }
-      }
-
-      return fileLastUpdated;
-    }
-
-    @Override
-    public Long nonNullRead(ColumnVector vector, int row) {
-      return read(vector, row);
-    }
-
-    @Override
-    public void setBatchContext(long batchOffsetInFile) {
-      if (fileSeqReader != null) {
-        fileSeqReader.setBatchContext(batchOffsetInFile);
-      }
-    }
-  }
 }
orc/.../data/orc/GenericOrcReaders.java vs apache/main
diff --git a/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java b/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java
index faa62f770..506c73801 100644
--- a/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java
+++ b/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReaders.java
@@ -20,7 +20,6 @@ package org.apache.iceberg.data.orc;
 
 import java.math.BigDecimal;
 import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
 import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import java.time.LocalDate;
@@ -40,9 +39,6 @@ import org.apache.iceberg.relocated.com.google.common.collect.Maps;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.util.DateTimeUtil;
 import org.apache.iceberg.util.UUIDUtil;
-import org.apache.iceberg.variants.Variant;
-import org.apache.iceberg.variants.VariantMetadata;
-import org.apache.iceberg.variants.VariantValue;
 import org.apache.orc.TypeDescription;
 import org.apache.orc.storage.ql.exec.vector.BytesColumnVector;
 import org.apache.orc.storage.ql.exec.vector.ColumnVector;
@@ -50,7 +46,6 @@ import org.apache.orc.storage.ql.exec.vector.DecimalColumnVector;
 import org.apache.orc.storage.ql.exec.vector.ListColumnVector;
 import org.apache.orc.storage.ql.exec.vector.LongColumnVector;
 import org.apache.orc.storage.ql.exec.vector.MapColumnVector;
-import org.apache.orc.storage.ql.exec.vector.StructColumnVector;
 import org.apache.orc.storage.ql.exec.vector.TimestampColumnVector;
 
 public class GenericOrcReaders {
@@ -117,10 +112,6 @@ public class GenericOrcReaders {
     return TimestampReader.INSTANCE;
   }
 
-  public static OrcValueReader<Variant> variants() {
-    return VariantReader.INSTANCE;
-  }
-
   private static class TimestampTzReader implements OrcValueReader<OffsetDateTime> {
     public static final OrcValueReader<OffsetDateTime> INSTANCE = new TimestampTzReader();
 
@@ -225,24 +216,6 @@ public class GenericOrcReaders {
     }
   }
 
-  private static class VariantReader implements OrcValueReader<Variant> {
-    private static final VariantReader INSTANCE = new VariantReader();
-
-    @Override
-    public Variant nonNullRead(ColumnVector vector, int row) {
-      StructColumnVector struct = (StructColumnVector) vector;
-      VariantMetadata metadata =
-          VariantMetadata.from(
-              BytesReader.INSTANCE.read(struct.fields[0], row).order(ByteOrder.LITTLE_ENDIAN));
-      VariantValue value =
-          VariantValue.from(
-              metadata,
-              BytesReader.INSTANCE.read(struct.fields[1], row).order(ByteOrder.LITTLE_ENDIAN));
-
-      return Variant.of(metadata, value);
-    }
-  }
-
   private static class StructReader extends OrcValueReaders.StructReader<Record> {
     private final GenericRecord template;
 
orc/.../data/orc/GenericOrcReader.java vs apache/main
diff --git a/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java b/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java
index 3bd8bfbfd..f394d64bf 100644
--- a/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java
+++ b/orc/src/main/java/org/apache/iceberg/data/orc/GenericOrcReader.java
@@ -94,15 +94,6 @@ public class GenericOrcReader implements OrcRowReader<Record> {
       return GenericOrcReaders.map(keyReader, valueReader);
     }
 
-    @Override
-    public OrcValueReader<?> variant(
-        Types.VariantType iVariant,
-        TypeDescription variant,
-        OrcValueReader<?> metadata,
-        OrcValueReader<?> value) {
-      return GenericOrcReaders.variants();
-    }
-
     @Override
     public OrcValueReader<?> primitive(Type.PrimitiveType iPrimitive, TypeDescription primitive) {
       if (iPrimitive == null) {

Spark 3.1

apache/main no longer ships spark/v3.1, so those two files have no upstream counterpart to diff against (they render as whole "added" files in the compare). The actual change to them is small — one line in SparkOrcReader and a ~30-line additive overload/ctor in SparkOrcValueReaders:

diff --git a/spark/v3.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcReader.java b/spark/v3.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcReader.java
index 78db13705..3d5323353 100644
--- a/spark/v3.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcReader.java
+++ b/spark/v3.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcReader.java
@@ -77,7 +77,7 @@ public class SparkOrcReader implements OrcRowReader<InternalRow> {
         TypeDescription record,
         List<String> names,
         List<OrcValueReader<?>> fields) {
-      return SparkOrcValueReaders.struct(fields, expected, idToConstant);
+      return SparkOrcValueReaders.struct(record, fields, expected, idToConstant);
     }
 
     @Override
diff --git a/spark/v3.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueReaders.java b/spark/v3.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueReaders.java
index 9e9b3e53b..fabb0868f 100644
--- a/spark/v3.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueReaders.java
+++ b/spark/v3.1/spark/src/main/java/org/apache/iceberg/spark/data/SparkOrcValueReaders.java
@@ -26,6 +26,7 @@ import org.apache.iceberg.orc.OrcValueReaders;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
 import org.apache.iceberg.relocated.com.google.common.collect.Lists;
 import org.apache.iceberg.types.Types;
+import org.apache.orc.TypeDescription;
 import org.apache.orc.storage.ql.exec.vector.BytesColumnVector;
 import org.apache.orc.storage.ql.exec.vector.ColumnVector;
 import org.apache.orc.storage.ql.exec.vector.DecimalColumnVector;
@@ -63,11 +64,25 @@ public class SparkOrcValueReaders {
     }
   }
 
+  /**
+   * @deprecated Use {@link #struct(TypeDescription, List, Types.StructType, Map)} instead. This
+   *     method uses position-based binding which may cause field misalignment in MOR and lineage
+   *     scenarios.
+   */
+  @Deprecated
   static OrcValueReader<?> struct(
       List<OrcValueReader<?>> readers, Types.StructType struct, Map<Integer, ?> idToConstant) {
     return new StructReader(readers, struct, idToConstant);
   }
 
+  static OrcValueReader<?> struct(
+      TypeDescription orcType,
+      List<OrcValueReader<?>> readers,
+      Types.StructType struct,
+      Map<Integer, ?> idToConstant) {
+    return new StructReader(orcType, readers, struct, idToConstant);
+  }
+
   static OrcValueReader<?> array(OrcValueReader<?> elementReader) {
     return new ArrayReader(elementReader);
   }
@@ -136,12 +151,27 @@ public class SparkOrcValueReaders {
   static class StructReader extends OrcValueReaders.StructReader<InternalRow> {
     private final int numFields;
 
+    /**
+     * @deprecated Use {@link #StructReader(TypeDescription, List, Types.StructType, Map)} instead.
+     *     This constructor uses position-based binding which may cause field misalignment in MOR
+     *     and lineage scenarios.
+     */
+    @Deprecated
     protected StructReader(
         List<OrcValueReader<?>> readers, Types.StructType struct, Map<Integer, ?> idToConstant) {
       super(readers, struct, idToConstant);
       this.numFields = struct.fields().size();
     }
 
+    protected StructReader(
+        TypeDescription orcType,
+        List<OrcValueReader<?>> readers,
+        Types.StructType struct,
+        Map<Integer, ?> idToConstant) {
+      super(orcType, readers, struct, idToConstant);
+      this.numFields = struct.fields().size();
+    }
+
     @Override
     protected InternalRow create() {
       return new GenericInternalRow(numFields);

@cbb330

cbb330 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Re: red CI on this PR — pre-existing infra, fixed by #258

The failing checks here are not caused by this change. Every test job auto-fails in ~2s at the "Prepare all required actions" step because the 1.2.x workflows pin the hard-deprecated actions/upload-artifact@v3:

This request has been automatically failed because it uses a deprecated version of `actions/upload-artifact: v3`.

Evidence it's unrelated to this PR:

Fix: #258 bumps upload-artifact@v3 → @v4 on 1.2.x. Because pull_request CI resolves workflow files from the PR's base, #258 needs to merge first; after that, a re-run (or rebase) of this PR will pick up the fixed workflows and go green. No code change needed here.

…readers

Backport the upstream id-based StructReader binding (apache/iceberg 77e7dbb, PR #15776) to the li-1.2.x ORC reader, adapted for the 1.2.x type system.

The ORC StructReader previously bound struct 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, which is resilient to field reordering and schema evolution.

Scope: only GenericOrcReaders and Spark 3.1 SparkOrcValueReaders are converted. The positional constructor/overloads are retained and marked @deprecated because the other engine readers (Spark 2.4/3.2/3.3, Flink) still use them. Row-lineage, UNKNOWN, and VARIANT constructs from the upstream commit are omitted as they do not exist on 1.2.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>
@cbb330

cbb330 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this was based on the wrong branch (1.2.x, which is essentially vanilla apache 1.2.x). The correct target is the OpenHouse dev branch openhouse-1.2.0, which already has the id-bound ORC readers and the NestedField column-default API (#250) — so the id-binding and API backports here were redundant.

Rebuilt as a clean 2-PR stack on openhouse-1.2.0:

@cbb330 cbb330 closed this Jul 23, 2026
@cbb330
cbb330 deleted the chbush/orc-id-binding-backport branch July 23, 2026 20:57
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.

1 participant