Skip to content

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

Open
cbb330 wants to merge 1 commit into
openhouse-1.2.0from
chbush/oh120-orc-id-binding
Open

ORC: bind struct fields by Iceberg field id in generic and Spark 3.1 readers#265
cbb330 wants to merge 1 commit into
openhouse-1.2.0from
chbush/oh120-orc-id-binding

Conversation

@cbb330

@cbb330 cbb330 commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

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

What changes: the ORC reader bound struct fields positionally — consuming ORC column vectors in projection order, assuming projected field order matches the file's physical column order. This 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: 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 default-fill work stacked on top.

How ID-based binding works (quick primer)

An ORC file stores columns in a physical order. Iceberg has an expected schema, and every field carries 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 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)

openhouse-1.2.0 lacks constructs the upstream commit references, so these are intentionally omitted (porting them verbatim would not compile here):

  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

These compare/orc-id-binding-* branches are disposable comparison artifacts, not intended for merge. They remain accurate for this PR: the six files here are byte-identical to the earlier 1.2.x-based backport in #256 (same patch ID 74c67920ff84).

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

History

This supersedes #256, which raised the same change against 1.2.x — a near-vanilla apache branch that is not the OpenHouse dev line. #256's closing note incorrectly stated that openhouse-1.2.0 already had ID-bound ORC readers; it did not (OrcValueReaders.java there was byte-identical to vanilla 1.2.x). This PR restores that work on the correct base. Note that shanthoosh's #250 (the NestedField column-default API, already merged here) has zero file overlap with this change.

Stack

  1. This PR — ID-based StructReader backport (base: openhouse-1.2.0)
  2. ORC: fill initial defaults for missing id-bound fields #263 — Generic ORC initial-default fill
  3. Spark 3.1: fill ORC initial defaults on the row reader #264 — Spark 3.1 ORC initial-default fill

The default-fill PRs build on this binding so physical columns and absent-field default constants are both associated by Iceberg field ID.

Testing Done

  • Unit tests added/updated — TestGenericOrcReaderIdBinding covers type promotion (int→long, float→double, decimal widening), reordered projection, _pos / _deleted metadata columns, and idToConstant
  • Missing-field and reader-count binding-invariant validation covered
  • Targeted ID-binding, Generic defaults, ORC projection, and Spark 3.1 defaults tests pass across the full stack
  • Spotless and targeted Java checkstyle pass; 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 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, the stacked PRs use those bound IDs to resolve an Iceberg field's initial-default for fields present in the expected schema but absent from the ORC file (which today read as null).


Made with Cursor

@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).

These six files are byte-identical to the earlier 1.2.x-raised backport in #256 (same patch ID 74c67920ff84); this PR re-bases that work onto openhouse-1.2.0, which never had ID-bound ORC readers.

// Maps each projected struct field position to the matching child index in the ORC schema.
// This allows fields to be read by Iceberg field ID when the projected struct order differs
// from the file schema.
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.

* This constructor uses position-based binding which may cause field misalignment in MOR
* scenarios.
*/
@Deprecated

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 (Spark 2.4/3.2/3.3 and Flink still call it on this line). Adaptation: this.orcFieldIndex = null; just below is the sentinel that makes readInternal fall back to exact positional behavior.

}
}

protected StructReader(

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 plus their handle* helpers) and the || field.type().typeId() == Type.TypeID.UNKNOWN clause are dropped — neither construct exists on openhouse-1.2.0.

this.readers[pos] = constants(false);
} else if (fileReader != null) {
this.isConstantOrMetadataField[pos] = false;
this.orcFieldIndex[pos] = fieldIdToOrcIndex.getOrDefault(field.fieldId(), -1);

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. The orcFieldIndex[pos] assignment is the core of ID binding: the expected field at pos records which ORC child index carries its field ID, so readInternal can select the right vector regardless of physical order.

}
}

private Map<Integer, Integer> buildFieldIdToOrcIndex(TypeDescription orcType) {

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 is verbatim from upstream — maps each ORC child's embedded Iceberg field ID to its physical index.

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).

return new StructReader(readers, struct, idToConstant);
}

static OrcValueReader<?> struct(

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 above as @Deprecated because Spark 2.4/3.2/3.3 and Flink still use it.

this.numFields = struct.fields().size();
}

protected StructReader(

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 just above.

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. Note upstream's Spark 3.5 reader already ships exactly this line.

* 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 including _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 28, 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 c8a4b9806. 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 openhouse-1.2.0; the ID-binding logic itself is verbatim.

orc/src/main/java/org/apache/iceberg/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/src/main/java/org/apache/iceberg/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/src/main/java/org/apache/iceberg/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). Compare against spark/v3.5/.../SparkOrcValueReaders.java on main for the equivalent upstream shape. The actual change to them is small — one line in SparkOrcReader and an additive overload/ctor in SparkOrcValueReaders:

commit d39cbfdcc6f882926bfb6305f4d4fae73dc67333
Author: Christian Bush <chbush@linkedin.com>
Date:   Wed Jul 22 19:40:04 2026 -0700

    ORC: bind struct fields by Iceberg field id in generic and Spark 3.1 readers
    
    Backport the upstream id-based StructReader binding (apache/iceberg 77e7dbb24, PR #15776) to the openhouse-1.2.0 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 openhouse-1.2.0.
    
    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>

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 28, 2026

Copy link
Copy Markdown
Collaborator Author
Noise-free compares vs apache/iceberg main

A native apache/main...this-branch compare isn't possible directly: linkedin/iceberg and apache/iceberg aren't in the same fork network, and such a compare would be dominated by unrelated fork/version divergence anyway. Two throwaway overlay branches on a fork of apache/iceberg (cbb330/iceberg-apache) give clean views instead.

1. This backport vs upstream — apache/main with only this PR's 6 files overlaid, so every other file is byte-identical to upstream and drops out of the diff:

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

Reading guide (6 files, behind_by: 0):

  • OrcValueReaders.java, GenericOrcReaders.java, GenericOrcReader.java — shown as modified; these paths exist on apache/main, so it's a true head-to-head. The "deletions" are upstream-only code intentionally not backported (row-lineage readers/branches, VARIANT, the UNKNOWN clause).
  • 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).
  • TestGenericOrcReaderIdBinding.javaadded (net-new test).

2. The work itself, as green additions — the 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 what this PR does — the positional → ID-based conversion — as additions:

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

  • orc-id-binding-baseline = apache/main with the pre-backport versions of the 5 touched source files overlaid
  • orc-id-binding-work = that baseline + this backport on top

Roughly: OrcValueReaders +87/−4 (ID-binding ctor, readersByFieldId / buildFieldIdToOrcIndex, readInternal selection, @Deprecated positional ctor); GenericOrcReaders +28 and SparkOrcValueReaders +28 (4-arg struct + subclass ctor); GenericOrcReader and SparkOrcReader +1/−1 each (visitor call sites pass record); TestGenericOrcReaderIdBinding +220 (net-new tests).

Still accurate for this PR. These overlays were built for #256, and this PR's six files are byte-identical to #256's (same patch ID 74c67920ff84), so the compares apply unchanged. Verification:

git diff 592778c52 <this-PR-head> -- <the 6 files>   # empty

All orc-id-binding-* branches on cbb330/iceberg-apache are disposable comparison artifacts, not for merge.

…readers

Backport the upstream id-based StructReader binding (apache/iceberg 77e7dbb, PR #15776) to the openhouse-1.2.0 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 openhouse-1.2.0.

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 force-pushed the chbush/oh120-orc-id-binding branch from d39cbfd to 4ca1b1e Compare July 28, 2026 03:11
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