This is an automated email from the ASF dual-hosted git repository.

rdblue pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 3957d26daa Core: Implement v4 first row ID assignment (#18225)
3957d26daa is described below

commit 3957d26daa535eb5f03e101f1210ee234e978f9a
Author: Ryan Blue <[email protected]>
AuthorDate: Thu Sep 24 11:59:34 2026 -0700

    Core: Implement v4 first row ID assignment (#18225)
---
 .../java/org/apache/iceberg/TrackingStruct.java    |  47 +++++++
 .../java/org/apache/iceberg/V4ManifestReader.java  |  36 ++++-
 .../org/apache/iceberg/TestTrackingStruct.java     |  65 +++++++++
 .../org/apache/iceberg/TestV4ManifestReader.java   | 153 +++++++++++++++++++++
 4 files changed, 297 insertions(+), 4 deletions(-)

diff --git a/core/src/main/java/org/apache/iceberg/TrackingStruct.java 
b/core/src/main/java/org/apache/iceberg/TrackingStruct.java
index 860fdda881..e5b3517111 100644
--- a/core/src/main/java/org/apache/iceberg/TrackingStruct.java
+++ b/core/src/main/java/org/apache/iceberg/TrackingStruct.java
@@ -116,6 +116,25 @@ class TrackingStruct extends SupportsIndexProjection 
implements Tracking, Serial
     this.replacedPositions = replacedPositions;
   }
 
+  /**
+   * Inherit a commit's snapshot ID if it is unset.
+   *
+   * <p>Used when rewriting uncommitted manifests before the sequence number 
is determined.
+   *
+   * @param manifestSnapshotId the manifest's snapshot ID
+   */
+  void inherit(long manifestSnapshotId) {
+    if (null == snapshotId) {
+      this.snapshotId = manifestSnapshotId;
+    }
+  }
+
+  /**
+   * Inherit a commit's snapshot ID and sequence number if either is unset.
+   *
+   * @param manifestSnapshotId the manifest's snapshot ID
+   * @param manifestSeqNumber the manifest's sequence number, determined by a 
successful commit
+   */
   void inherit(long manifestSnapshotId, long manifestSeqNumber) {
     if (null == snapshotId) {
       this.snapshotId = manifestSnapshotId;
@@ -132,6 +151,34 @@ class TrackingStruct extends SupportsIndexProjection 
implements Tracking, Serial
     }
   }
 
+  /**
+   * Assign the first row ID to the given next row ID if it is unassigned.
+   *
+   * <p>If the {@code nextRowId} is null, the first row ID will also be set to 
null. This is used
+   * when reading snapshots from older format versions that do not have 
assigned row IDs.
+   *
+   * @param nextRowId the next row ID to assign, or null when reading v2 or 
earlier snapshots
+   * @return true if the first row ID is assigned a non-null value
+   */
+  boolean assignFirstRowId(Long nextRowId) {
+    if (null == nextRowId) {
+      // null manifest first row ID from pre-v3 upgrade path
+      // defensively set the first row ID for every entry to be null
+      this.firstRowId = null;
+      return false;
+    }
+
+    // ADDED will assign for new files
+    // EXISTING will assign to handle existing files upgraded from pre-v3
+    // MODIFIED must assign when a pre-v3 EXISTING file is modified in the 
first v4 commit
+    if (status.isLive() && null == firstRowId) {
+      this.firstRowId = nextRowId;
+      return true;
+    }
+
+    return false;
+  }
+
   @Override
   public EntryStatus status() {
     return status;
diff --git a/core/src/main/java/org/apache/iceberg/V4ManifestReader.java 
b/core/src/main/java/org/apache/iceberg/V4ManifestReader.java
index 6ee7486ff5..1122cb5750 100644
--- a/core/src/main/java/org/apache/iceberg/V4ManifestReader.java
+++ b/core/src/main/java/org/apache/iceberg/V4ManifestReader.java
@@ -59,7 +59,12 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
 
   static Builder builder(
       ManifestFile manifest, FileIO io, Schema tableSchema, Map<Integer, 
PartitionSpec> specsById) {
-    return new Builder(manifest, io, tableSchema, specsById);
+    return new Builder(manifest, io, tableSchema, specsById, false /* 
committed */);
+  }
+
+  static Builder uncommitted(
+      ManifestFile manifest, FileIO io, Schema tableSchema, Map<Integer, 
PartitionSpec> specsById) {
+    return new Builder(manifest, io, tableSchema, specsById, true /* 
uncommitted */);
   }
 
   private final ManifestFile manifest;
@@ -70,8 +75,11 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
   private final Map<Integer, Pair<Evaluator, StructProjection>> 
partitionFilters; // by spec ID
   private final boolean includeAll;
   private final Set<Integer> requestedStatsFieldIds;
+  private final boolean isUncommitted;
   private final ScanMetrics scanMetrics;
 
+  private Long nextRowId = null;
+
   private V4ManifestReader(
       ManifestFile manifest,
       FileIO io,
@@ -81,6 +89,7 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
       Map<Integer, Pair<Evaluator, StructProjection>> partitionFilters,
       boolean includeAll,
       Set<Integer> requestedStatsFieldIds,
+      boolean isUncommitted,
       ScanMetrics scanMetrics) {
     this.manifest = manifest;
     this.io = io;
@@ -90,7 +99,11 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
     this.partitionFilters = partitionFilters;
     this.includeAll = includeAll;
     this.requestedStatsFieldIds = requestedStatsFieldIds;
+    this.isUncommitted = isUncommitted;
     this.scanMetrics = scanMetrics;
+
+    // initialize the next row ID to the manifest's first row ID
+    this.nextRowId = manifest.firstRowId();
   }
 
   @VisibleForTesting
@@ -130,7 +143,16 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
   private TrackedFile applyInheritance(TrackedFile file) {
     // the reader uses TrackingStruct to read tracking so this cast is safe
     TrackingStruct tracking = (TrackingStruct) file.tracking();
-    tracking.inherit(manifest.snapshotId(), manifest.sequenceNumber());
+    if (isUncommitted) {
+      // uncommitted files cannot have a sequence number or assign first row ID
+      tracking.inherit(manifest.snapshotId());
+    } else {
+      tracking.inherit(manifest.snapshotId(), manifest.sequenceNumber());
+      if (tracking.assignFirstRowId(nextRowId)) {
+        this.nextRowId += file.recordCount();
+      }
+    }
+
     return file;
   }
 
@@ -235,6 +257,7 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
     private final Schema tableSchema;
     private final Types.StructType unionPartitionType;
     private final Map<Integer, PartitionSpec> specsById;
+    private final boolean isUncommitted;
     private String tableLocation = null;
     private Expression rowFilter = Expressions.alwaysTrue();
     private boolean caseSensitive = true;
@@ -250,7 +273,8 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
         ManifestFile manifest,
         FileIO io,
         Schema tableSchema,
-        Map<Integer, PartitionSpec> specsById) {
+        Map<Integer, PartitionSpec> specsById,
+        boolean isUncommitted) {
       Preconditions.checkArgument(tableSchema != null, "Invalid table schema: 
null");
       int formatVersion = manifest.formatVersion();
       Preconditions.checkArgument(
@@ -275,8 +299,9 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
       this.manifest = manifest;
       this.io = io;
       this.tableSchema = tableSchema;
-      this.specsById = specsById;
       this.unionPartitionType = 
Partitioning.unionPartitionTypes(specsById.values());
+      this.specsById = specsById;
+      this.isUncommitted = isUncommitted;
     }
 
     /**
@@ -311,6 +336,8 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
 
     /** Configures the reader to select the minimal fields needed for scan 
planning. */
     Builder forScanPlanning() {
+      Preconditions.checkState(
+          !isUncommitted, "Cannot plan scan using uncommitted manifest: %s", 
manifest.path());
       Preconditions.checkState(
           requestedColumns == null && requestedProjection == null,
           "Cannot use forScanPlanning() with select(Iterable<String>) or 
project(Schema)");
@@ -395,6 +422,7 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
           partitionFilters,
           includeAll,
           requestedStatsFieldIds,
+          isUncommitted,
           scanMetrics);
     }
 
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java 
b/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
index d5f0e8d425..253384a683 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
@@ -133,6 +133,17 @@ class TestTrackingStruct {
     
assertThat(copy.replacedPositions().array()).isNotSameAs(tracking.replacedPositions().array());
   }
 
+  @ParameterizedTest
+  @EnumSource(EntryStatus.class)
+  void inheritSnapshotIdOnly(EntryStatus status) {
+    TrackingStruct tracking = new TrackingStruct(status, null, null, null, 
null, null, null, null);
+
+    tracking.inherit(100L);
+
+    assertThat(tracking.snapshotId()).isEqualTo(100L);
+    assertThat(tracking.dataSequenceNumber()).isNull();
+  }
+
   @ParameterizedTest
   @EnumSource(EntryStatus.class)
   void inheritSnapshotId(EntryStatus status) {
@@ -193,6 +204,60 @@ class TestTrackingStruct {
     assertThat(tracking.fileSequenceNumber()).isNull();
   }
 
+  private static final List<EntryStatus> ASSIGNING_STATUSES =
+      List.of(EntryStatus.ADDED, EntryStatus.EXISTING, EntryStatus.MODIFIED);
+  private static final List<EntryStatus> NON_ASSIGNING_STATUSES =
+      List.of(EntryStatus.DELETED, EntryStatus.REPLACED);
+
+  @ParameterizedTest
+  @FieldSource("ASSIGNING_STATUSES")
+  void assignmentFirstRowIdAssigned(EntryStatus status) {
+    TrackingStruct tracking = new TrackingStruct(status, 42L, null, null, 
null, null, null, null);
+
+    assertThat(tracking.assignFirstRowId(10_000L)).as("Should return true when 
assigned").isTrue();
+
+    assertThat(tracking.firstRowId()).isEqualTo(10_000L);
+  }
+
+  @ParameterizedTest
+  @FieldSource("NON_ASSIGNING_STATUSES")
+  void assignmentFirstRowIdNotAssigned(EntryStatus status) {
+    TrackingStruct tracking = new TrackingStruct(status, 42L, null, null, 
null, null, null, null);
+
+    assertThat(tracking.assignFirstRowId(10_000L))
+        .as("Should return false when not assigned")
+        .isFalse();
+
+    assertThat(tracking.firstRowId()).isNull();
+  }
+
+  @ParameterizedTest
+  @EnumSource(EntryStatus.class)
+  void assignmentFirstRowIdAlreadyAssigned(EntryStatus status) {
+    TrackingStruct tracking = new TrackingStruct(status, 42L, null, null, 
null, 5_000L, null, null);
+
+    assertThat(tracking.assignFirstRowId(10_000L))
+        .as("Should return false when not assigned")
+        .isFalse();
+
+    assertThat(tracking.firstRowId())
+        .as("Should use existing value when already assigned")
+        .isEqualTo(5_000L);
+  }
+
+  @ParameterizedTest
+  @EnumSource(EntryStatus.class)
+  void assignmentNullFirstRowId(EntryStatus status) {
+    TrackingStruct tracking =
+        new TrackingStruct(status, 42L, null, null, null, 10_000L, null, null);
+
+    assertThat(tracking.assignFirstRowId(null))
+        .as("Should return false when not assigned")
+        .isFalse();
+
+    assertThat(tracking.firstRowId()).as("Should override a value with 
null").isNull();
+  }
+
   @ParameterizedTest
   @EnumSource(EntryStatus.class)
   void isLiveDelegatesToStatus(EntryStatus status) {
diff --git a/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java 
b/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
index c7c6f76a4f..0a9c55ced6 100644
--- a/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
+++ b/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
@@ -345,6 +345,44 @@ class TestV4ManifestReader {
         .containsExactlyElementsOf(files);
   }
 
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void inheritanceUncommittedOnlyInheritsSnapshotId(FileFormat format) 
throws IOException {
+    Tracking trackingWithSnapshotId =
+        new TrackingStruct(EntryStatus.ADDED, 1234567L, null, null, null, 
null, null, null);
+    TrackedFile withSnapshotId =
+        unpartitionedDataFile(trackingWithSnapshotId, 
"s3://bucket/table/file-b.parquet");
+    Tracking trackingWithoutSnapshotId =
+        new TrackingStruct(EntryStatus.ADDED, null, null, null, null, null, 
null, null);
+    TrackedFile withoutSnapshotId =
+        unpartitionedDataFile(trackingWithoutSnapshotId, 
"s3://bucket/table/file-a.parquet");
+
+    ManifestFile manifest =
+        writeManifest(
+            format, UNPARTITIONED_TYPE, ImmutableList.of(withSnapshotId, 
withoutSnapshotId));
+
+    assertThat(manifest.sequenceNumber())
+        .as("Manifest metadata has a sequence number that will not be 
inherited")
+        .isNotNull();
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.uncommitted(manifest, IO, TABLE_SCHEMA, 
ID_PARTITIONING_SPECS)
+            .metricsConfig(METRICS_CONFIG);
+    List<TrackedFile> actual = read(builder);
+
+    assertThat(actual)
+        .extracting(file -> file.tracking().snapshotId())
+        .containsExactly(1234567L, SNAPSHOT_ID);
+    assertThat(actual)
+        .as("Uncommitted files do not inherit data sequence number")
+        .extracting(file -> file.tracking().dataSequenceNumber())
+        .containsOnlyNulls();
+    assertThat(actual)
+        .as("Uncommitted files do not inherit file sequence number")
+        .extracting(file -> file.tracking().fileSequenceNumber())
+        .containsOnlyNulls();
+  }
+
   @ParameterizedTest
   @FieldSource("MANIFEST_FORMATS")
   public void inheritanceSnapshotId(FileFormat format) throws IOException {
@@ -447,6 +485,121 @@ class TestV4ManifestReader {
         .containsExactly(MANIFEST_SEQ, 500L);
   }
 
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void inheritanceFirstRowId(FileFormat format) throws IOException {
+    Tracking trackingWithoutFirstRowId =
+        new TrackingStruct(EntryStatus.ADDED, SNAPSHOT_ID, null, null, null, 
null, null, null);
+    TrackedFile withoutFirstRowId =
+        unpartitionedDataFile(trackingWithoutFirstRowId, 
"s3://bucket/table/file-a.parquet");
+    Tracking trackingWithFirstRowId =
+        new TrackingStruct(EntryStatus.EXISTING, SNAPSHOT_ID, null, null, 
null, 5_000L, null, null);
+    TrackedFile withFirstRowId =
+        unpartitionedDataFile(trackingWithFirstRowId, 
"s3://bucket/table/file-c.parquet");
+
+    ManifestFile manifest =
+        writeManifest(
+            format, UNPARTITIONED_TYPE, ImmutableList.of(withoutFirstRowId, 
withFirstRowId));
+    when(manifest.firstRowId()).thenReturn(10_000L);
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
ID_PARTITIONING_SPECS)
+            .metricsConfig(METRICS_CONFIG);
+    List<TrackedFile> actual = read(builder);
+
+    assertThat(actual)
+        .extracting(file -> file.tracking().firstRowId())
+        .containsExactly(10_000L, 5_000L);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void inheritanceFirstRowIdIncrementsByRecordCount(FileFormat format) 
throws IOException {
+    Tracking trackingWithoutFirstRowId =
+        new TrackingStruct(EntryStatus.ADDED, SNAPSHOT_ID, null, null, null, 
null, null, null);
+    Tracking trackingWithFirstRowId =
+        new TrackingStruct(EntryStatus.EXISTING, SNAPSHOT_ID, null, null, 
null, 5_000L, null, null);
+
+    TrackedFile fileA =
+        unpartitionedDataFile(trackingWithoutFirstRowId, 
"s3://bucket/table/file-a.parquet");
+    TrackedFile fileB =
+        unpartitionedDataFile(trackingWithoutFirstRowId, 
"s3://bucket/table/file-b.parquet");
+    TrackedFile fileC =
+        unpartitionedDataFile(trackingWithoutFirstRowId, 
"s3://bucket/table/file-c.parquet");
+    TrackedFile fileD =
+        unpartitionedDataFile(trackingWithFirstRowId, 
"s3://bucket/table/file-d.parquet");
+    TrackedFile fileE =
+        unpartitionedDataFile(trackingWithoutFirstRowId, 
"s3://bucket/table/file-e.parquet");
+
+    ManifestFile manifest =
+        writeManifest(
+            format, UNPARTITIONED_TYPE, ImmutableList.of(fileA, fileB, fileC, 
fileD, fileE));
+    when(manifest.firstRowId()).thenReturn(10_000L);
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
ID_PARTITIONING_SPECS)
+            .metricsConfig(METRICS_CONFIG);
+    List<TrackedFile> actual = read(builder);
+
+    assertThat(actual)
+        .as("Each assignment should increment by rocord_count=100")
+        .extracting(file -> file.tracking().firstRowId())
+        .containsExactly(10_000L, 10_100L, 10_200L, 5_000L, 10_300L);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void inheritanceUncommittedSkipsFirstRowId(FileFormat format) throws 
IOException {
+    Tracking trackingWithoutFirstRowId =
+        new TrackingStruct(EntryStatus.ADDED, SNAPSHOT_ID, null, null, null, 
null, null, null);
+    TrackedFile withoutFirstRowId =
+        unpartitionedDataFile(trackingWithoutFirstRowId, 
"s3://bucket/table/file-a.parquet");
+    Tracking trackingWithFirstRowId =
+        new TrackingStruct(EntryStatus.EXISTING, SNAPSHOT_ID, null, null, 
null, 5_000L, null, null);
+    TrackedFile withFirstRowId =
+        unpartitionedDataFile(trackingWithFirstRowId, 
"s3://bucket/table/file-c.parquet");
+
+    ManifestFile manifest =
+        writeManifest(
+            format, UNPARTITIONED_TYPE, ImmutableList.of(withoutFirstRowId, 
withFirstRowId));
+    when(manifest.firstRowId()).thenReturn(10_000L);
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.uncommitted(manifest, IO, TABLE_SCHEMA, 
ID_PARTITIONING_SPECS)
+            .metricsConfig(METRICS_CONFIG);
+    List<TrackedFile> actual = read(builder);
+
+    assertThat(actual)
+        .extracting(file -> file.tracking().firstRowId())
+        .containsExactly(null, 5_000L);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void inheritanceFirstRowIdNullFromUpgradedTable(FileFormat format) 
throws IOException {
+    Tracking trackingWithoutFirstRowId =
+        new TrackingStruct(EntryStatus.ADDED, SNAPSHOT_ID, null, null, null, 
null, null, null);
+    TrackedFile withoutFirstRowId =
+        unpartitionedDataFile(trackingWithoutFirstRowId, 
"s3://bucket/table/file-a.parquet");
+    Tracking trackingWithFirstRowId =
+        new TrackingStruct(EntryStatus.EXISTING, SNAPSHOT_ID, null, null, 
null, 5_000L, null, null);
+    TrackedFile withFirstRowId =
+        unpartitionedDataFile(trackingWithFirstRowId, 
"s3://bucket/table/file-c.parquet");
+
+    ManifestFile manifest =
+        writeManifest(
+            format, UNPARTITIONED_TYPE, ImmutableList.of(withoutFirstRowId, 
withFirstRowId));
+    // first row ID is null in upgraded tables, which is propagated to all 
files
+    when(manifest.firstRowId()).thenReturn(null);
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
ID_PARTITIONING_SPECS)
+            .metricsConfig(METRICS_CONFIG);
+    List<TrackedFile> actual = read(builder);
+
+    assertThat(actual).extracting(file -> 
file.tracking().firstRowId()).containsOnlyNulls();
+  }
+
   @ParameterizedTest
   @FieldSource("MANIFEST_FORMATS")
   public void inheritanceManifestLocationAndPosition(FileFormat format) throws 
IOException {

Reply via email to