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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new d187f675d0 [iceberg] Assign manifest-level row lineage for Iceberg 
format version 3 (#9245)
d187f675d0 is described below

commit d187f675d04ce5924beee04d0ba9d821dca79de1
Author: Victor Babenko <[email protected]>
AuthorDate: Wed Aug 19 19:56:46 2026 -0700

    [iceberg] Assign manifest-level row lineage for Iceberg format version 3 
(#9245)
---
 .github/workflows/utitcase-iceberg-ga.yml          |  66 ++
 .../paimon/iceberg/IcebergCommitCallback.java      | 181 +++-
 .../iceberg/manifest/IcebergDataFileMeta.java      |  44 +-
 .../manifest/IcebergDataFileMetaSerializer.java    |  27 +-
 .../iceberg/manifest/IcebergManifestEntry.java     |  13 +-
 .../manifest/IcebergManifestEntrySerializer.java   |   8 +-
 .../iceberg/manifest/IcebergManifestFile.java      |  14 +-
 .../iceberg/manifest/IcebergManifestFileMeta.java  |  49 +-
 .../IcebergManifestFileMetaSerializer.java         |  30 +-
 .../iceberg/manifest/IcebergManifestList.java      |   5 +-
 paimon-iceberg/pom.xml                             |  94 ++-
 .../iceberg/IcebergRestMetadataCommitter.java      | 214 ++++-
 .../core/IcebergRowLineageCompatibilityTest.java   | 917 +++++++++++++++++++++
 .../apache/paimon/iceberg/IcebergMetadataTest.java |  16 +-
 .../iceberg/IcebergRestMetadataCommitterTest.java  | 310 +++++++
 15 files changed, 1914 insertions(+), 74 deletions(-)

diff --git a/.github/workflows/utitcase-iceberg-ga.yml 
b/.github/workflows/utitcase-iceberg-ga.yml
new file mode 100644
index 0000000000..deca2434fc
--- /dev/null
+++ b/.github/workflows/utitcase-iceberg-ga.yml
@@ -0,0 +1,66 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+name: UTCase Iceberg GA row lineage on JDK 17
+
+on:
+  push:
+    paths:
+      - 'paimon-iceberg/**'
+      - 'paimon-core/**'
+      - 'paimon-common/**'
+      - 'paimon-api/**'
+      - 'paimon-format/**'
+      - 'pom.xml'
+      - '.github/workflows/utitcase-iceberg-ga.yml'
+  pull_request:
+    paths:
+      - 'paimon-iceberg/**'
+      - 'paimon-core/**'
+      - 'paimon-common/**'
+      - 'paimon-api/**'
+      - 'paimon-format/**'
+      - 'pom.xml'
+      - '.github/workflows/utitcase-iceberg-ga.yml'
+
+env:
+  JDK_VERSION: 17
+  MAVEN_OPTS: -Dmaven.wagon.httpconnectionManager.ttlSeconds=30 
-Dmaven.wagon.http.retryHandler.requestSentEnabled=true
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event_name }}-${{ 
github.event.number || github.run_id }}
+  cancel-in-progress: true
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v6
+      - name: Set up JDK ${{ env.JDK_VERSION }}
+        uses: actions/setup-java@v5
+        with:
+          java-version: ${{ env.JDK_VERSION }}
+          distribution: 'temurin'
+      - name: Build
+        run: mvn -T 1C -B -ntp clean install -DskipTests -pl paimon-iceberg 
-am -Ppaimon-iceberg,iceberg-ga
+      - name: Test against GA Iceberg
+        run: mvn -B -ntp test -pl paimon-iceberg -Ppaimon-iceberg,iceberg-ga
+        env:
+          MAVEN_OPTS: -Xmx4096m
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
index 42e6ce2a8f..f8a41f56f2 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java
@@ -538,6 +538,16 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
         metrics.totalPositionDeletes = totalPositionDeleteRecords;
         metrics.totalEqualityDeletes = 0;
 
+        // a rebuild replaces metadata whose ids are already out with readers: 
never reuse them
+        Long snapshotFirstRowId = computeSnapshotFirstRowId(nextRowIdFloor);
+        ManifestRowIdAssignment rowIdAssignment =
+                assignManifestFirstRowIds(allManifestFileMetas, 
snapshotFirstRowId);
+        allManifestFileMetas = rowIdAssignment.manifests;
+        Long addedRows = snapshotFirstRowId == null ? null : 
rowIdAssignment.assignedRows;
+        Long nextRowId =
+                snapshotFirstRowId == null
+                        ? null
+                        : snapshotFirstRowId + rowIdAssignment.assignedRows;
         String manifestListFileName = 
manifestList.writeWithoutRolling(allManifestFileMetas);
 
         // current schema follows the latest; the snapshot entry records its 
own schema
@@ -551,8 +561,6 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
                 computeSnapshotSummary(
                         IcebergSnapshotSummary.APPEND.operation(), 
paimonSnapshot, metrics);
 
-        // a rebuild replaces metadata whose ids are already out with readers: 
never reuse them
-        RowLineage rowLineage = computeRowLineage(nextRowIdFloor, 
metrics.addedRecords);
         IcebergSnapshot snapshot =
                 new IcebergSnapshot(
                         snapshotId,
@@ -563,8 +571,8 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
                         snapshotSummary,
                         
pathFactory.toManifestListPath(manifestListFileName).toString(),
                         snapshotSchemaId,
-                        rowLineage.firstRowId,
-                        rowLineage.addedRows);
+                        snapshotFirstRowId,
+                        addedRows);
 
         // Tags can only be included in Iceberg if they point to an Iceberg 
snapshot that
         // exists. Otherwise, an Iceberg client fails to parse the metadata 
and all reads fail.
@@ -607,7 +615,7 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
                                         IcebergPartitionField.FIRST_FIELD_ID - 
1),
                         Collections.singletonList(snapshot),
                         (int) snapshotId,
-                        rowLineage.nextRowId,
+                        nextRowId,
                         refs);
 
         Path metadataPath = pathFactory.toMetadataPath(snapshotId);
@@ -1086,13 +1094,6 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
         // compact data manifest file if needed
         newDataManifestFileMetas = 
compactMetadataIfNeeded(newDataManifestFileMetas, snapshotId);
 
-        String manifestListFileName =
-                manifestList.writeWithoutRolling(
-                        Stream.concat(
-                                        newDataManifestFileMetas.stream(),
-                                        newDVManifestFileMetas.stream())
-                                .collect(Collectors.toList()));
-
         SummaryMetrics metrics = new SummaryMetrics();
         metrics.addedDataFiles = addedFiles.size();
         metrics.addedRecords =
@@ -1143,6 +1144,24 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
         metrics.totalPositionDeletes = 
computeLiveRowCount(newDVManifestFileMetas);
         metrics.totalEqualityDeletes = 0;
 
+        Long snapshotFirstRowId = computeSnapshotFirstRowId(rowIdFloor);
+
+        ManifestRowIdAssignment rowIdAssignment =
+                assignManifestFirstRowIds(
+                        Stream.concat(
+                                        newDataManifestFileMetas.stream(),
+                                        newDVManifestFileMetas.stream())
+                                .collect(Collectors.toList()),
+                        snapshotFirstRowId);
+        List<IcebergManifestFileMeta> newManifestFileMetasWithRowIds = 
rowIdAssignment.manifests;
+        Long addedRows = snapshotFirstRowId == null ? null : 
rowIdAssignment.assignedRows;
+        Long nextRowId =
+                snapshotFirstRowId == null
+                        ? null
+                        : snapshotFirstRowId + rowIdAssignment.assignedRows;
+        String manifestListFileName =
+                
manifestList.writeWithoutRolling(newManifestFileMetasWithRowIds);
+
         IcebergSnapshotSummary snapshotSummary =
                 computeSnapshotSummary(operation, snapshot, metrics);
 
@@ -1161,8 +1180,6 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
         }
         // a schema-pointer rollback (validated above): only the current 
pointer moves
 
-        RowLineage rowLineage = computeRowLineage(rowIdFloor, 
metrics.addedRecords);
-
         List<IcebergSnapshot> snapshots = new 
ArrayList<>(baseMetadata.snapshots());
         snapshots.add(
                 new IcebergSnapshot(
@@ -1175,8 +1192,8 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
                         
pathFactory.toManifestListPath(manifestListFileName).toString(),
                         // the snapshot's own schema, for time travel
                         snapshotSchemaId,
-                        rowLineage.firstRowId,
-                        rowLineage.addedRows));
+                        snapshotFirstRowId,
+                        addedRows));
 
         // all snapshots in this list, except the last one, need to expire
         List<IcebergSnapshot> toExpireExceptLast = new ArrayList<>();
@@ -1221,7 +1238,7 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
                         baseMetadata.lastPartitionId(),
                         snapshots,
                         (int) snapshotId,
-                        rowLineage.nextRowId,
+                        nextRowId,
                         refs);
 
         Path metadataPath = pathFactory.toMetadataPath(snapshotId);
@@ -1426,8 +1443,10 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
                             commitKind == Snapshot.CommitKind.COMPACT
                                     ? 
IcebergSnapshotSummary.REPLACE.operation()
                                     : 
IcebergSnapshotSummary.OVERWRITE.operation();
+                    List<IcebergManifestEntry> sourceEntries =
+                            materializeFirstRowIds(fileMeta, entries);
                     List<IcebergManifestEntry> newEntries = new ArrayList<>();
-                    for (IcebergManifestEntry entry : entries) {
+                    for (IcebergManifestEntry entry : sourceEntries) {
                         if (entry.isLive()) {
                             boolean removed = 
removedFiles.containsKey(entry.file().filePath());
                             newEntries.add(
@@ -1489,10 +1508,13 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
 
         Function<IcebergManifestFileMeta, List<IcebergManifestEntry>> 
processor =
                 meta -> {
+                    List<IcebergManifestEntry> sourceEntries =
+                            materializeFirstRowIds(
+                                    meta,
+                                    IcebergManifestFile.create(table, 
pathFactory)
+                                            .read(new 
Path(meta.manifestPath()).getName()));
                     List<IcebergManifestEntry> entries = new ArrayList<>();
-                    for (IcebergManifestEntry entry :
-                            IcebergManifestFile.create(table, pathFactory)
-                                    .read(new 
Path(meta.manifestPath()).getName())) {
+                    for (IcebergManifestEntry entry : sourceEntries) {
                         // a deletion made by this commit is recorded against 
the current
                         // snapshot but keeps the file sequence number of the 
older snapshot
                         // that added the file, so it has to be recognised by 
snapshot id
@@ -1553,9 +1575,14 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
     }
 
     private void expireManifestList(String toExpire, String next) {
-        Set<IcebergManifestFileMeta> metaInUse = new 
HashSet<>(manifestList.read(next));
+        // compare by physical path: a carried-over manifest may be re-listed 
with different
+        // list-level fields (e.g. an assigned first_row_id) while sharing the 
same file
+        Set<String> pathsInUse = new HashSet<>();
+        for (IcebergManifestFileMeta meta : manifestList.read(next)) {
+            pathsInUse.add(meta.manifestPath());
+        }
         for (IcebergManifestFileMeta meta : manifestList.read(toExpire)) {
-            if (metaInUse.contains(meta)) {
+            if (pathsInUse.contains(meta.manifestPath())) {
                 continue;
             }
             table.fileIO().deleteQuietly(new Path(meta.manifestPath()));
@@ -2001,24 +2028,102 @@ public class IcebergCommitCallback implements 
CommitCallback, TagCallback {
 
     /**
      * Row-lineage bookkeeping for a new snapshot, mandatory in Iceberg format 
version 3: the
-     * snapshot's first-row-id starts at the base metadata's next-row-id 
watermark and the table's
-     * next-row-id advances by the snapshot's added records. For format 
version 2 all fields stay
-     * null so nothing is written.
+     * snapshot's first-row-id starts at the base metadata's next-row-id 
watermark. The snapshot's
+     * added-rows and the table's next-row-id are NOT derived here: they 
depend on how many rows
+     * {@link #assignManifestFirstRowIds} actually assigns (which can exceed 
this commit's added
+     * records when a carried-over manifest is assigned for the first time, 
e.g. a Layer-1-written
+     * manifest being upgraded), so callers must recompute them from the 
assignment's result. For
+     * format version 2 the field stays null so nothing is written.
+     */
+    @Nullable
+    private Long computeSnapshotFirstRowId(long baseNextRowId) {
+        return formatVersion >= IcebergMetadata.FORMAT_VERSION_V3 ? 
baseNextRowId : null;
+    }
+
+    /**
+     * Result of {@link #assignManifestFirstRowIds}: the manifests with 
first_row_id assigned, and
+     * the total number of rows actually consumed from the row-id space by 
that assignment (which
+     * may be larger than this commit's added-records count; see the 
class-level note there).
+     */
+    private static class ManifestRowIdAssignment {
+        private final List<IcebergManifestFileMeta> manifests;
+        private final long assignedRows;
+
+        private ManifestRowIdAssignment(
+                List<IcebergManifestFileMeta> manifests, long assignedRows) {
+            this.manifests = manifests;
+            this.assignedRows = assignedRows;
+        }
+    }
+
+    /**
+     * Iceberg v3: assign first_row_id (field 520) to data manifests that do 
not have one yet.
+     * Manifests carried over from base metadata that are already assigned 
keep their value; delete
+     * manifests stay null. The watermark starts at the snapshot's 
first-row-id and advances by each
+     * newly-assigned manifest's TRUE inheriting-rows count (see {@link 
#trueInheritingRowsCount}),
+     * returned as {@link ManifestRowIdAssignment#assignedRows}.
+     *
+     * <p>A manifest written entirely under manifest-level assignment 
satisfies "inheriting rows ==
+     * ADDED rows", so the bound is exact for it. A manifest carried over from 
before assignment
+     * existed may hold EXISTING entries whose field 142 is also still null; 
the bound covers them
+     * without reading the manifest, at the cost of spec-legal id gaps when 
some of those entries
+     * were already materialized. DELETED entries never inherit ids and are 
excluded. Callers MUST
+     * use {@code assignedRows} (not this commit's added-records count) to 
advance the snapshot's
+     * added-rows / table next-row-id, precisely because of that mismatch.
      */
-    private RowLineage computeRowLineage(long baseNextRowId, long 
addedRecords) {
-        RowLineage lineage = new RowLineage();
-        if (formatVersion >= IcebergMetadata.FORMAT_VERSION_V3) {
-            lineage.firstRowId = baseNextRowId;
-            lineage.addedRows = addedRecords;
-            lineage.nextRowId = baseNextRowId + addedRecords;
-        }
-        return lineage;
+    private ManifestRowIdAssignment assignManifestFirstRowIds(
+            List<IcebergManifestFileMeta> manifests, @Nullable Long 
snapshotFirstRowId) {
+        if (snapshotFirstRowId == null) {
+            return new ManifestRowIdAssignment(manifests, 0L);
+        }
+        List<IcebergManifestFileMeta> result = new ArrayList<>();
+        long watermark = snapshotFirstRowId;
+        for (IcebergManifestFileMeta meta : manifests) {
+            if (meta.content() == IcebergManifestFileMeta.Content.DATA
+                    && meta.firstRowId() == null) {
+                result.add(meta.withFirstRowId(watermark));
+                // spec-sanctioned upper bound: only ADDED and EXISTING rows 
can inherit
+                // ids from this manifest (readers never assign ids to DELETED 
entries).
+                // Rows whose field 142 is already materialized merely widen 
the reserved
+                // range, leaving legal id gaps - in exchange the commit path 
never has to
+                // read manifest contents.
+                watermark += meta.addedRowsCount() + meta.existingRowsCount();
+            } else {
+                result.add(meta);
+            }
+        }
+        return new ManifestRowIdAssignment(result, watermark - 
snapshotFirstRowId);
     }
 
-    private static class RowLineage {
-        @Nullable private Long firstRowId;
-        @Nullable private Long addedRows;
-        @Nullable private Long nextRowId;
+    /**
+     * Iceberg v3 requires the inherited first_row_id to be written into file 
metadata when entries
+     * are copied into a rewritten manifest. Computes each entry's effective 
id in base manifest
+     * order (explicit field 142, or inherited from the manifest's 
first_row_id, skipping DELETED
+     * entries exactly like GA readers do) and returns entries with the id 
materialized. No-op for
+     * delete manifests and for base manifests without an assigned 
first_row_id (v2 metadata, or v3
+     * metadata written before manifest-level assignment existed — those stay 
in the spec's
+     * upgraded-table state).
+     */
+    private static List<IcebergManifestEntry> materializeFirstRowIds(
+            IcebergManifestFileMeta baseMeta, List<IcebergManifestEntry> 
entries) {
+        if (baseMeta.content() != IcebergManifestFileMeta.Content.DATA
+                || baseMeta.firstRowId() == null) {
+            return entries;
+        }
+        List<IcebergManifestEntry> result = new ArrayList<>();
+        long watermark = baseMeta.firstRowId();
+        for (IcebergManifestEntry entry : entries) {
+            if (entry.status() != IcebergManifestEntry.Status.DELETED
+                    && entry.file().firstRowId() == null) {
+                
result.add(entry.withFile(entry.file().withFirstRowId(watermark)));
+                watermark += entry.file().recordCount();
+            } else {
+                // DELETED entries never inherit an id (GA readers skip them 
when
+                // assigning), so their field 142 stays null and the walk does 
not advance
+                result.add(entry);
+            }
+        }
+        return result;
     }
 
     private class SchemaCache {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergDataFileMeta.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergDataFileMeta.java
index 950da63b9d..f9ce30bad4 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergDataFileMeta.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergDataFileMeta.java
@@ -87,6 +87,7 @@ public class IcebergDataFileMeta {
     private final String referencedDataFile;
     private final Long contentOffset;
     private final Long contentSizeInBytes;
+    @Nullable private final Long firstRowId;
 
     IcebergDataFileMeta(
             Content content,
@@ -110,6 +111,7 @@ public class IcebergDataFileMeta {
                 upperBounds,
                 null,
                 null,
+                null,
                 null);
     }
 
@@ -125,7 +127,8 @@ public class IcebergDataFileMeta {
             InternalMap upperBounds,
             String referencedDataFile,
             Long contentOffset,
-            Long contentSizeInBytes) {
+            Long contentSizeInBytes,
+            @Nullable Long firstRowId) {
         this.content = content;
         this.filePath = filePath;
         this.fileFormat = fileFormat;
@@ -139,6 +142,7 @@ public class IcebergDataFileMeta {
         this.referencedDataFile = referencedDataFile;
         this.contentOffset = contentOffset;
         this.contentSizeInBytes = contentSizeInBytes;
+        this.firstRowId = firstRowId;
     }
 
     public static IcebergDataFileMeta create(
@@ -245,7 +249,8 @@ public class IcebergDataFileMeta {
                 null,
                 referencedDataFile,
                 contentOffset,
-                contentSizeInBytes);
+                contentSizeInBytes,
+                null);
     }
 
     public Content content() {
@@ -296,7 +301,33 @@ public class IcebergDataFileMeta {
         return contentSizeInBytes;
     }
 
+    @Nullable
+    public Long firstRowId() {
+        return firstRowId;
+    }
+
+    public IcebergDataFileMeta withFirstRowId(long firstRowId) {
+        return new IcebergDataFileMeta(
+                content,
+                filePath,
+                fileFormat,
+                partition,
+                recordCount,
+                fileSizeInBytes,
+                nullValueCounts,
+                lowerBounds,
+                upperBounds,
+                referencedDataFile,
+                contentOffset,
+                contentSizeInBytes,
+                firstRowId);
+    }
+
     public static RowType schema(RowType partitionType) {
+        return schema(partitionType, false);
+    }
+
+    public static RowType schema(RowType partitionType, boolean 
withFirstRowId) {
         List<DataField> fields = new ArrayList<>();
         fields.add(new DataField(134, "content", DataTypes.INT().notNull()));
         fields.add(new DataField(100, "file_path", 
DataTypes.STRING().notNull()));
@@ -322,6 +353,9 @@ public class IcebergDataFileMeta {
         fields.add(new DataField(143, "referenced_data_file", 
DataTypes.STRING()));
         fields.add(new DataField(144, "content_offset", DataTypes.BIGINT()));
         fields.add(new DataField(145, "content_size_in_bytes", 
DataTypes.BIGINT()));
+        if (withFirstRowId) {
+            fields.add(new DataField(142, "first_row_id", DataTypes.BIGINT()));
+        }
         return new RowType(false, fields);
     }
 
@@ -345,7 +379,8 @@ public class IcebergDataFileMeta {
                 && Objects.equals(upperBounds, that.upperBounds)
                 && Objects.equals(referencedDataFile, that.referencedDataFile)
                 && Objects.equals(contentOffset, that.contentOffset)
-                && Objects.equals(contentSizeInBytes, that.contentSizeInBytes);
+                && Objects.equals(contentSizeInBytes, that.contentSizeInBytes)
+                && Objects.equals(firstRowId, that.firstRowId);
     }
 
     @Override
@@ -362,6 +397,7 @@ public class IcebergDataFileMeta {
                 upperBounds,
                 referencedDataFile,
                 contentOffset,
-                contentSizeInBytes);
+                contentSizeInBytes,
+                firstRowId);
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergDataFileMetaSerializer.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergDataFileMetaSerializer.java
index 020b5e1b44..5db5dea27d 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergDataFileMetaSerializer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergDataFileMetaSerializer.java
@@ -36,18 +36,40 @@ public class IcebergDataFileMetaSerializer extends 
ObjectSerializer<IcebergDataF
     private final InternalMapSerializer nullValueCountsSerializer;
     private final InternalMapSerializer lowerBoundsSerializer;
     private final InternalMapSerializer upperBoundsSerializer;
+    private final boolean withFirstRowId;
 
     public IcebergDataFileMetaSerializer(RowType partitionType) {
-        super(IcebergDataFileMeta.schema(partitionType));
+        this(partitionType, false);
+    }
+
+    public IcebergDataFileMetaSerializer(RowType partitionType, boolean 
withFirstRowId) {
+        super(IcebergDataFileMeta.schema(partitionType, withFirstRowId));
         this.partSerializer = new InternalRowSerializer(partitionType);
         this.nullValueCountsSerializer =
                 new InternalMapSerializer(DataTypes.INT(), DataTypes.BIGINT());
         this.lowerBoundsSerializer = new 
InternalMapSerializer(DataTypes.INT(), DataTypes.BYTES());
         this.upperBoundsSerializer = new 
InternalMapSerializer(DataTypes.INT(), DataTypes.BYTES());
+        this.withFirstRowId = withFirstRowId;
     }
 
     @Override
     public InternalRow toRow(IcebergDataFileMeta file) {
+        if (withFirstRowId) {
+            return GenericRow.of(
+                    file.content().id(),
+                    BinaryString.fromString(file.filePath()),
+                    BinaryString.fromString(file.fileFormat()),
+                    file.partition(),
+                    file.recordCount(),
+                    file.fileSizeInBytes(),
+                    file.nullValueCounts(),
+                    file.lowerBounds(),
+                    file.upperBounds(),
+                    BinaryString.fromString(file.referencedDataFile()),
+                    file.contentOffset(),
+                    file.contentSizeInBytes(),
+                    file.firstRowId());
+        }
         return GenericRow.of(
                 file.content().id(),
                 BinaryString.fromString(file.filePath()),
@@ -77,6 +99,7 @@ public class IcebergDataFileMetaSerializer extends 
ObjectSerializer<IcebergDataF
                 upperBoundsSerializer.copy(row.getMap(8)),
                 row.isNullAt(9) ? null : row.getString(9).toString(),
                 row.isNullAt(10) ? null : row.getLong(10),
-                row.isNullAt(11) ? null : row.getLong(11));
+                row.isNullAt(11) ? null : row.getLong(11),
+                withFirstRowId && !row.isNullAt(12) ? row.getLong(12) : null);
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestEntry.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestEntry.java
index 40aea8c498..727c6cf570 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestEntry.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestEntry.java
@@ -111,7 +111,16 @@ public class IcebergManifestEntry {
         return dataFile;
     }
 
+    public IcebergManifestEntry withFile(IcebergDataFileMeta file) {
+        return new IcebergManifestEntry(
+                status, snapshotId, sequenceNumber, fileSequenceNumber, file);
+    }
+
     public static RowType schema(RowType partitionType) {
+        return schema(partitionType, false);
+    }
+
+    public static RowType schema(RowType partitionType, boolean 
withFirstRowId) {
 
         RowType icebergPartition = icebergPartitionType(partitionType);
 
@@ -122,7 +131,9 @@ public class IcebergManifestEntry {
         fields.add(new DataField(4, "file_sequence_number", 
DataTypes.BIGINT()));
         fields.add(
                 new DataField(
-                        2, "data_file", 
IcebergDataFileMeta.schema(icebergPartition).notNull()));
+                        2,
+                        "data_file",
+                        IcebergDataFileMeta.schema(icebergPartition, 
withFirstRowId).notNull()));
         return new RowType(false, fields);
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestEntrySerializer.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestEntrySerializer.java
index b9d2c271b5..75172706a8 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestEntrySerializer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestEntrySerializer.java
@@ -31,8 +31,12 @@ public class IcebergManifestEntrySerializer extends 
ObjectSerializer<IcebergMani
     private final IcebergDataFileMetaSerializer fileSerializer;
 
     public IcebergManifestEntrySerializer(RowType partitionType) {
-        super(IcebergManifestEntry.schema(partitionType));
-        this.fileSerializer = new IcebergDataFileMetaSerializer(partitionType);
+        this(partitionType, false);
+    }
+
+    public IcebergManifestEntrySerializer(RowType partitionType, boolean 
withFirstRowId) {
+        super(IcebergManifestEntry.schema(partitionType, withFirstRowId));
+        this.fileSerializer = new IcebergDataFileMetaSerializer(partitionType, 
withFirstRowId);
     }
 
     @Override
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFile.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFile.java
index f2f11a697c..5e43a2e2fa 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFile.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFile.java
@@ -31,6 +31,7 @@ import org.apache.paimon.fs.Path;
 import org.apache.paimon.iceberg.IcebergOptions;
 import org.apache.paimon.iceberg.IcebergPathFactory;
 import org.apache.paimon.iceberg.manifest.IcebergManifestFileMeta.Content;
+import org.apache.paimon.iceberg.metadata.IcebergMetadata;
 import org.apache.paimon.iceberg.metadata.IcebergPartitionSpec;
 import org.apache.paimon.io.RollingFileWriterImpl;
 import org.apache.paimon.io.SingleFileWriter;
@@ -72,6 +73,7 @@ public class IcebergManifestFile extends 
ObjectsFile<IcebergManifestEntry> {
     public IcebergManifestFile(
             FileIO fileIO,
             RowType partitionType,
+            boolean withFirstRowId,
             FormatReaderFactory readerFactory,
             FormatWriterFactory writerFactory,
             String compression,
@@ -79,8 +81,8 @@ public class IcebergManifestFile extends 
ObjectsFile<IcebergManifestEntry> {
             MemorySize targetFileSize) {
         super(
                 fileIO,
-                new IcebergManifestEntrySerializer(partitionType),
-                IcebergManifestEntry.schema(partitionType),
+                new IcebergManifestEntrySerializer(partitionType, 
withFirstRowId),
+                IcebergManifestEntry.schema(partitionType, withFirstRowId),
                 readerFactory,
                 writerFactory,
                 compression,
@@ -98,8 +100,10 @@ public class IcebergManifestFile extends 
ObjectsFile<IcebergManifestEntry> {
 
     public static IcebergManifestFile create(FileStoreTable table, 
IcebergPathFactory pathFactory) {
         RowType partitionType = table.schema().logicalPartitionType();
-        RowType entryType = IcebergManifestEntry.schema(partitionType);
         Options avroOptions = Options.fromMap(table.options());
+        boolean withFirstRowId =
+                avroOptions.get(IcebergOptions.FORMAT_VERSION) >= 
IcebergMetadata.FORMAT_VERSION_V3;
+        RowType entryType = IcebergManifestEntry.schema(partitionType, 
withFirstRowId);
         // 
https://github.com/apache/iceberg/blob/main/core/src/main/java/org/apache/iceberg/ManifestReader.java
         avroOptions.set(
                 "avro.row-name-mapping",
@@ -120,6 +124,7 @@ public class IcebergManifestFile extends 
ObjectsFile<IcebergManifestEntry> {
         return new IcebergManifestFile(
                 table.fileIO(),
                 partitionType,
+                withFirstRowId,
                 manifestFileAvro.createReaderFactory(entryType, entryType, new 
ArrayList<>()),
                 manifestFileAvro.createWriterFactory(entryType),
                 avroOptions.get(IcebergOptions.MANIFEST_COMPRESSION),
@@ -298,7 +303,8 @@ public class IcebergManifestFile extends 
ObjectsFile<IcebergManifestEntry> {
                     addedRowsCount,
                     existingRowsCount,
                     deletedRowsCount,
-                    partitionSummaries);
+                    partitionSummaries,
+                    null);
         }
     }
 
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFileMeta.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFileMeta.java
index da3e0c2402..d5ac07c962 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFileMeta.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFileMeta.java
@@ -22,6 +22,8 @@ import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataTypes;
 import org.apache.paimon.types.RowType;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
@@ -73,6 +75,7 @@ public class IcebergManifestFileMeta {
     private final long existingRowsCount;
     private final long deletedRowsCount;
     private final List<IcebergPartitionSummary> partitions;
+    @Nullable private final Long firstRowId;
 
     public IcebergManifestFileMeta(
             String manifestPath,
@@ -88,7 +91,8 @@ public class IcebergManifestFileMeta {
             long addedRowsCount,
             long existingRowsCount,
             long deletedRowsCount,
-            List<IcebergPartitionSummary> partitions) {
+            List<IcebergPartitionSummary> partitions,
+            @Nullable Long firstRowId) {
         this.manifestPath = manifestPath;
         this.manifestLength = manifestLength;
         this.partitionSpecId = partitionSpecId;
@@ -103,6 +107,7 @@ public class IcebergManifestFileMeta {
         this.existingRowsCount = existingRowsCount;
         this.deletedRowsCount = deletedRowsCount;
         this.partitions = partitions;
+        this.firstRowId = firstRowId;
     }
 
     public String manifestPath() {
@@ -165,8 +170,42 @@ public class IcebergManifestFileMeta {
         return partitions;
     }
 
+    @Nullable
+    public Long firstRowId() {
+        return firstRowId;
+    }
+
+    public IcebergManifestFileMeta withFirstRowId(long firstRowId) {
+        return new IcebergManifestFileMeta(
+                manifestPath,
+                manifestLength,
+                partitionSpecId,
+                content,
+                sequenceNumber,
+                minSequenceNumber,
+                addedSnapshotId,
+                addedFilesCount,
+                existingFilesCount,
+                deletedFilesCount,
+                addedRowsCount,
+                existingRowsCount,
+                deletedRowsCount,
+                partitions,
+                firstRowId);
+    }
+
     public static RowType schema(boolean legacyVersion) {
-        return legacyVersion ? schemaForIceberg1_4() : schemaForIcebergNew();
+        return schema(legacyVersion, false);
+    }
+
+    public static RowType schema(boolean legacyVersion, boolean 
withFirstRowId) {
+        RowType base = legacyVersion ? schemaForIceberg1_4() : 
schemaForIcebergNew();
+        if (!withFirstRowId) {
+            return base;
+        }
+        List<DataField> fields = new ArrayList<>(base.getFields());
+        fields.add(new DataField(520, "first_row_id", DataTypes.BIGINT()));
+        return new RowType(false, fields);
     }
 
     private static RowType schemaForIcebergNew() {
@@ -235,7 +274,8 @@ public class IcebergManifestFileMeta {
                 && addedRowsCount == that.addedRowsCount
                 && existingRowsCount == that.existingRowsCount
                 && deletedRowsCount == that.deletedRowsCount
-                && Objects.equals(partitions, that.partitions);
+                && Objects.equals(partitions, that.partitions)
+                && Objects.equals(firstRowId, that.firstRowId);
     }
 
     @Override
@@ -254,6 +294,7 @@ public class IcebergManifestFileMeta {
                 addedRowsCount,
                 existingRowsCount,
                 deletedRowsCount,
-                partitions);
+                partitions,
+                firstRowId);
     }
 }
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFileMetaSerializer.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFileMetaSerializer.java
index 2b4c9b771c..14b6bbf91b 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFileMetaSerializer.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestFileMetaSerializer.java
@@ -36,14 +36,41 @@ public class IcebergManifestFileMetaSerializer extends 
ObjectSerializer<IcebergM
     private static final long serialVersionUID = 1L;
 
     private final IcebergPartitionSummarySerializer partitionSummarySerializer;
+    private final boolean withFirstRowId;
 
     public IcebergManifestFileMetaSerializer(RowType schema) {
         super(schema);
         this.partitionSummarySerializer = new 
IcebergPartitionSummarySerializer();
+        // IcebergManifestFileMeta.schema(...) always has 14 base fields (see
+        // schemaForIcebergNew/schemaForIceberg1_4) and appends exactly one 
extra field,
+        // first_row_id (520), when withFirstRowId=true (see schema(boolean, 
boolean)); so a
+        // field count of 15 unambiguously means the row-lineage column is 
present.
+        this.withFirstRowId = schema.getFieldCount() == 15;
     }
 
     @Override
     public InternalRow toRow(IcebergManifestFileMeta file) {
+        if (withFirstRowId) {
+            return GenericRow.of(
+                    BinaryString.fromString(file.manifestPath()),
+                    file.manifestLength(),
+                    file.partitionSpecId(),
+                    file.content().id(),
+                    file.sequenceNumber(),
+                    file.minSequenceNumber(),
+                    file.addedSnapshotId(),
+                    file.addedFilesCount(),
+                    file.existingFilesCount(),
+                    file.deletedFilesCount(),
+                    file.addedRowsCount(),
+                    file.existingRowsCount(),
+                    file.deletedRowsCount(),
+                    new GenericArray(
+                            file.partitions().stream()
+                                    .map(partitionSummarySerializer::toRow)
+                                    .toArray(InternalRow[]::new)),
+                    file.firstRowId());
+        }
         return GenericRow.of(
                 BinaryString.fromString(file.manifestPath()),
                 file.manifestLength(),
@@ -80,7 +107,8 @@ public class IcebergManifestFileMetaSerializer extends 
ObjectSerializer<IcebergM
                 row.getLong(10),
                 row.getLong(11),
                 row.getLong(12),
-                toPartitionSummaries(row.getArray(13)));
+                toPartitionSummaries(row.getArray(13)),
+                withFirstRowId && !row.isNullAt(14) ? row.getLong(14) : null);
     }
 
     private List<IcebergPartitionSummary> toPartitionSummaries(InternalArray 
array) {
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestList.java
 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestList.java
index 9eb1194c6f..0439aba087 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestList.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/iceberg/manifest/IcebergManifestList.java
@@ -23,6 +23,7 @@ import org.apache.paimon.format.FileFormat;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.iceberg.IcebergOptions;
 import org.apache.paimon.iceberg.IcebergPathFactory;
+import org.apache.paimon.iceberg.metadata.IcebergMetadata;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.table.FileStoreTable;
 import org.apache.paimon.types.RowType;
@@ -69,9 +70,11 @@ public class IcebergManifestList extends 
ObjectsFile<IcebergManifestFileMeta> {
                         + "manifest_file_partitions:r508,"
                         + "array_id_r508:508");
         FileFormat fileFormat = FileFormat.fromIdentifier("avro", avroOptions);
+        boolean withFirstRowId =
+                avroOptions.get(IcebergOptions.FORMAT_VERSION) >= 
IcebergMetadata.FORMAT_VERSION_V3;
         RowType manifestType =
                 IcebergManifestFileMeta.schema(
-                        
avroOptions.get(IcebergOptions.MANIFEST_LEGACY_VERSION));
+                        
avroOptions.get(IcebergOptions.MANIFEST_LEGACY_VERSION), withFirstRowId);
         return new IcebergManifestList(
                 table.fileIO(),
                 fileFormat,
diff --git a/paimon-iceberg/pom.xml b/paimon-iceberg/pom.xml
index 403419d69b..bf02653af2 100644
--- a/paimon-iceberg/pom.xml
+++ b/paimon-iceberg/pom.xml
@@ -38,6 +38,7 @@ under the License.
         <iceberg.flink.version>1.19</iceberg.flink.version>
         <hive.version>2.3.10</hive.version>
         
<iceberg.flink.dropwizard.version>1.19.0</iceberg.flink.dropwizard.version>
+        <jetty.server.version>11.0.24</jetty.server.version>
     </properties>
 
     <dependencies>
@@ -192,6 +193,14 @@ under the License.
                 <exclusion>
                     <groupId>commons-io</groupId>
                     <artifactId>commons-io</artifactId>
+                </exclusion>
+                            <exclusion>
+                    <groupId>org.eclipse.jetty</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.eclipse.jetty.websocket</groupId>
+                    <artifactId>*</artifactId>
                 </exclusion>
             </exclusions>
         </dependency>
@@ -229,6 +238,14 @@ under the License.
                 <exclusion>
                     <groupId>commons-io</groupId>
                     <artifactId>commons-io</artifactId>
+                </exclusion>
+                            <exclusion>
+                    <groupId>org.eclipse.jetty</groupId>
+                    <artifactId>*</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>org.eclipse.jetty.websocket</groupId>
+                    <artifactId>*</artifactId>
                 </exclusion>
             </exclusions>
         </dependency>
@@ -275,7 +292,7 @@ under the License.
         <dependency>
             <groupId>org.eclipse.jetty</groupId>
             <artifactId>jetty-server</artifactId>
-            <version>11.0.24</version>
+            <version>${jetty.server.version}</version>
             <scope>test</scope>
         </dependency>
 
@@ -356,4 +373,79 @@ under the License.
 
     </dependencies>
 
+
+    <profiles>
+        <profile>
+            <!-- GA row-lineage validation: builds and runs this module 
against Iceberg 1.11,
+                 the reference implementation of Iceberg format-version 3 row 
lineage. Iceberg
+                 1.10+ ships Java-17 bytecode, so this profile requires JDK 17 
and stays
+                 opt-in: the default build keeps Iceberg 1.8.1 so the module 
compiles and
+                 tests on the JDK 11 CI, where GA-only reader assertions skip 
themselves.
+
+                 Run as two invocations (as the iceberg-ga CI workflow does):
+                   mvn install -DskipTests -pl paimon-iceberg -am 
-Ppaimon-iceberg,iceberg-ga
+                   mvn test -pl paimon-iceberg -Ppaimon-iceberg,iceberg-ga
+                 A single `test -am` session substitutes the not-yet-shaded 
paimon-bundle with
+                 its unshaded constituent modules, whose direct Avro 
references clash with the
+                 Avro 1.12 line Iceberg 1.11 requires; the installed bundle 
relocates Avro and
+                 has no such clash. -->
+            <id>iceberg-ga</id>
+            <properties>
+                <iceberg.version>1.11.0</iceberg.version>
+                <iceberg.flink.version>1.20</iceberg.flink.version>
+                
<iceberg.flink.dropwizard.version>1.20.0</iceberg.flink.dropwizard.version>
+                <!-- Iceberg 1.11's REST test-fixtures embed a Jetty 12 server 
built on the
+                     jetty-compression module; the Jetty 11 jetty-server no 
longer provides
+                     the classes it needs. -->
+                <jetty.server.version>12.1.8</jetty.server.version>
+                <!-- Iceberg 1.11's iceberg-parquet pulls 
parquet-avro/parquet-hadoop 1.17.1,
+                     whose HadoopInputFile always calls the 
FileSystem#openFile(Path) builder
+                     API (Hadoop 3.3+); the reactor-wide hadoop.version 
predates it, so this
+                     profile pins the Hadoop 3 line Iceberg 1.11 itself builds 
against. -->
+                <hadoop.version>3.4.3</hadoop.version>
+            </properties>
+            <dependencies>
+                <!-- iceberg-core 1.11 requires jackson-core/jackson-databind 
2.21.x, but
+                     Hadoop's older jackson-annotations otherwise wins 
dependency mediation,
+                     leaving a mismatched pair that fails at runtime. -->
+                <dependency>
+                    <groupId>com.fasterxml.jackson.core</groupId>
+                    <artifactId>jackson-annotations</artifactId>
+                    <version>2.21</version>
+                    <scope>provided</scope>
+                </dependency>
+
+                <dependency>
+                    <groupId>org.eclipse.jetty.ee10</groupId>
+                    <artifactId>jetty-ee10-servlet</artifactId>
+                    <version>12.1.8</version>
+                    <scope>test</scope>
+                </dependency>
+
+                <!-- direct declaration so the Jetty 12 security classes win 
dependency
+                     mediation over the Jetty 11 line pulled by the default 
fixture's
+                     jetty-servlet -->
+                <dependency>
+                    <groupId>org.eclipse.jetty</groupId>
+                    <artifactId>jetty-security</artifactId>
+                    <version>12.1.8</version>
+                    <scope>test</scope>
+                </dependency>
+
+                <dependency>
+                    <groupId>org.eclipse.jetty.compression</groupId>
+                    <artifactId>jetty-compression-server</artifactId>
+                    <version>12.1.8</version>
+                    <scope>test</scope>
+                </dependency>
+
+                <dependency>
+                    <groupId>org.eclipse.jetty.compression</groupId>
+                    <artifactId>jetty-compression-gzip</artifactId>
+                    <version>12.1.8</version>
+                    <scope>test</scope>
+                </dependency>
+            </dependencies>
+        </profile>
+    </profiles>
 </project>
\ No newline at end of file
diff --git 
a/paimon-iceberg/src/main/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitter.java
 
b/paimon-iceberg/src/main/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitter.java
index bb86c53a6f..adeec647f1 100644
--- 
a/paimon-iceberg/src/main/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitter.java
+++ 
b/paimon-iceberg/src/main/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitter.java
@@ -20,6 +20,7 @@ package org.apache.paimon.iceberg;
 
 import org.apache.paimon.annotation.VisibleForTesting;
 import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.iceberg.metadata.IcebergMetadata;
 import org.apache.paimon.iceberg.metadata.IcebergSchema;
@@ -45,6 +46,7 @@ import org.apache.iceberg.catalog.Catalog;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.rest.Endpoint;
 import org.apache.iceberg.rest.RESTCatalog;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.types.Types.NestedField;
@@ -53,6 +55,8 @@ import org.slf4j.LoggerFactory;
 
 import javax.annotation.Nullable;
 
+import java.io.IOException;
+import java.lang.reflect.Field;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -60,6 +64,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 import java.util.stream.Collectors;
 
 import static org.apache.iceberg.CatalogUtil.ICEBERG_CATALOG_TYPE;
@@ -80,6 +85,8 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
     private static final String REST_CATALOG_NAME = "rest-catalog";
 
     private final RESTCatalog restCatalog;
+    private final FileIO fileIO;
+    private final Path metadataDirectory;
     private final String icebergDatabaseName;
     private final TableIdentifier icebergTableIdentifier;
     private final IcebergOptions icebergOptions;
@@ -89,6 +96,8 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
     public IcebergRestMetadataCommitter(FileStoreTable table) {
         Options options = new Options(table.options());
         icebergOptions = new IcebergOptions(options);
+        this.fileIO = table.fileIO();
+        this.metadataDirectory = 
IcebergCommitCallback.catalogTableMetadataPath(table);
 
         Identifier identifier = 
Preconditions.checkNotNull(table.catalogEnvironment().identifier());
         String icebergDatabase = 
options.get(IcebergOptions.METASTORE_DATABASE);
@@ -152,6 +161,11 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
 
         try {
             if (!tableExists()) {
+                if (requiresRegistration(newIcebergMetadata)) {
+                    LOG.info("Table {} does not exist, register it.", 
icebergTableIdentifier);
+                    registerAsCurrent(newIcebergMetadata, newMetadata, false);
+                    return;
+                }
                 LOG.info("Table {} does not exist, create it.", 
icebergTableIdentifier);
                 icebergTable = createTable(newMetadata);
                 updateBuilder =
@@ -192,6 +206,12 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
                     LOG.info(
                             "Iceberg table {} exists but has no snapshots, 
treating as new table.",
                             icebergTableIdentifier);
+                    if (requiresRegistration(newIcebergMetadata)) {
+                        // registration has no post-create commit step, so the 
drop+create
+                        // failure loop this branch guards against cannot occur
+                        registerAsCurrent(newIcebergMetadata, newMetadata, 
true);
+                        return;
+                    }
                     updateBuilder = updatesForCorrectBase(metadata, 
newMetadata, true);
                 } else {
                     boolean withBase = checkBase(metadata, newMetadata, 
baseIcebergMetadata);
@@ -205,6 +225,13 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
                                 newMetadata.currentSnapshot() != null
                                         ? 
newMetadata.currentSnapshot().snapshotId()
                                         : "No snapshot");
+                        if (requiresRegistration(newIcebergMetadata)) {
+                            LOG.info(
+                                    "the base metadata is incorrect, 
re-registering the iceberg"
+                                            + " table from local metadata.");
+                            registerAsCurrent(newIcebergMetadata, newMetadata, 
true);
+                            return;
+                        }
                         updateBuilder = updatesForIncorrectBase(newMetadata);
                     }
                 }
@@ -322,6 +349,172 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
         }
     }
 
+    /**
+     * Whether publishing {@code metadata} to a new or recreated catalog table 
must go through
+     * {@link RESTCatalog#registerTable}. The create/update path replays only 
the current snapshot
+     * through {@link TableMetadata.Builder}, which derives the table's 
next-row-id from that
+     * snapshot's added-rows alone, so the server ends at {@code added-rows} 
while the local
+     * watermark is {@code first-row-id + added-rows}. Any nonzero 
first-row-id (rollback and
+     * self-heal rebuilds) would leave the server below ids already assigned 
in manifests, and a
+     * later external writer could reuse them; registration imports the 
metadata verbatim. Format
+     * version 2 tables and zero-based v3 metadata keep the create/update 
path, which every REST
+     * catalog supports.
+     */
+    private static boolean requiresRegistration(IcebergMetadata metadata) {
+        IcebergSnapshot current = metadata.currentSnapshot();
+        return metadata.formatVersion() >= IcebergMetadata.FORMAT_VERSION_V3
+                && current != null
+                && current.firstRowId() != null
+                && current.firstRowId() != 0;
+    }
+
+    /**
+     * Makes the catalog's state exactly the (REST-adjusted) local metadata by 
registering a
+     * metadata file, instead of rebuilding the table through {@link 
TableMetadata.Builder}; see
+     * {@link #requiresRegistration}. Fails before touching the catalog table 
when the server does
+     * not support registration.
+     */
+    private void registerAsCurrent(
+            IcebergMetadata adjustedMetadata, TableMetadata newMetadata, 
boolean dropFirst) {
+        if (!registerTableAdvertised()) {
+            throw registerTableUnsupported(
+                    "The Iceberg REST catalog does not advertise 
registerTable",
+                    adjustedMetadata,
+                    null);
+        }
+        try {
+            Path registerPath = writeRegisterFile(adjustedMetadata);
+            if (dropFirst) {
+                if (probeRegisterTable(registerPath)) {
+                    // the table disappeared concurrently and the probe 
registered it
+                    verifyRegistered(newMetadata);
+                    return;
+                }
+                dropTable();
+            }
+            icebergTable =
+                    restCatalog.registerTable(icebergTableIdentifier, 
registerPath.toString());
+            verifyRegistered(newMetadata);
+        } catch (UnsupportedOperationException e) {
+            throw e;
+        } catch (Exception e) {
+            throw new RuntimeException(
+                    "Fail to register iceberg table " + 
icebergTableIdentifier, e);
+        }
+    }
+
+    /**
+     * Whether the server advertises the register-table endpoint. Iceberg's 
REST client keeps the
+     * endpoint set from the server's {@code /v1/config} response (or its 
backwards-compatible
+     * defaults, register-table included, when the server advertises none) in 
{@code
+     * RESTSessionCatalog#endpoints}, without a public accessor.
+     */
+    private boolean registerTableAdvertised() {
+        try {
+            Field sessionField = 
RESTCatalog.class.getDeclaredField("sessionCatalog");
+            sessionField.setAccessible(true);
+            Object sessionCatalog = sessionField.get(restCatalog);
+            Field endpointsField = null;
+            for (Class<?> c = sessionCatalog.getClass(); c != null; c = 
c.getSuperclass()) {
+                try {
+                    endpointsField = c.getDeclaredField("endpoints");
+                    break;
+                } catch (NoSuchFieldException ignored) {
+                    // keep looking in the superclass
+                }
+            }
+            if (endpointsField == null) {
+                throw new NoSuchFieldException("endpoints");
+            }
+            endpointsField.setAccessible(true);
+            Set<?> endpoints = (Set<?>) endpointsField.get(sessionCatalog);
+            return endpoints.contains(Endpoint.V1_REGISTER_TABLE);
+        } catch (Exception | LinkageError e) {
+            LOG.warn(
+                    "Cannot read the endpoints advertised by the Iceberg REST 
catalog, "
+                            + "assuming registerTable is unsupported.",
+                    e);
+            return false;
+        }
+    }
+
+    /**
+     * A server that advertises no endpoint list gets the client's default 
list, register-table
+     * included, so {@link #registerTableAdvertised} alone cannot prove 
support. Before the existing
+     * table is dropped, the registration is attempted against it: a server 
that implements the
+     * endpoint rejects it with {@link AlreadyExistsException}; anything else 
means the endpoint is
+     * unavailable and the existing table is left untouched.
+     *
+     * @return true if the registration went through because the table no 
longer existed
+     */
+    private boolean probeRegisterTable(Path registerPath) {
+        try {
+            icebergTable =
+                    restCatalog.registerTable(icebergTableIdentifier, 
registerPath.toString());
+            return true;
+        } catch (AlreadyExistsException e) {
+            return false;
+        } catch (RuntimeException e) {
+            throw registerTableUnsupported(
+                    "registerTable against the existing Iceberg REST catalog 
table failed with "
+                            + "something other than AlreadyExists, so the 
endpoint is assumed "
+                            + "unsupported",
+                    null,
+                    e);
+        }
+    }
+
+    private UnsupportedOperationException registerTableUnsupported(
+            String reason, @Nullable IcebergMetadata metadata, @Nullable 
Throwable cause) {
+        return new UnsupportedOperationException(
+                String.format(
+                        "%s; registerTable is required to publish format 
version 3 metadata "
+                                + "whose row-id space does not start at 0%s. 
The catalog table "
+                                + "%s was left untouched.",
+                        reason,
+                        metadata == null
+                                ? ""
+                                : String.format(
+                                        " (current snapshot first-row-id %s, 
next-row-id %s)",
+                                        
metadata.currentSnapshot().firstRowId(),
+                                        metadata.nextRowId()),
+                        icebergTableIdentifier),
+                cause);
+    }
+
+    /**
+     * Writes the metadata to register into a fresh, never-overwritten file. A 
catalog may keep
+     * referencing the registered location, and rollback and rebuild paths 
reuse Paimon snapshot ids
+     * for different timelines, so the name carries a UUID and an existing 
file is never replaced.
+     */
+    private Path writeRegisterFile(IcebergMetadata metadata) throws 
IOException {
+        Path registerPath =
+                new Path(
+                        metadataDirectory,
+                        String.format(
+                                "rest-register-v%d-%s.metadata.json",
+                                metadata.currentSnapshotId(), 
UUID.randomUUID()));
+        if (!fileIO.tryToWriteAtomic(registerPath, metadata.toJson())) {
+            throw new IOException("Metadata file to register already exists: " 
+ registerPath);
+        }
+        return registerPath;
+    }
+
+    private void verifyRegistered(TableMetadata newMetadata) {
+        long registered =
+                ((BaseTable) 
icebergTable).operations().current().currentSnapshot().snapshotId();
+        if (newMetadata.currentSnapshot() == null
+                || registered != newMetadata.currentSnapshot().snapshotId()) {
+            throw new IllegalStateException(
+                    String.format(
+                            "Registered catalog table is at snapshot %s 
instead of %s",
+                            registered,
+                            newMetadata.currentSnapshot() == null
+                                    ? "null"
+                                    : 
newMetadata.currentSnapshot().snapshotId()));
+        }
+    }
+
     private Table createTable(TableMetadata newMetadata) {
         /*
         Handles fieldId incompatibility between Paimon (starts at 0) and 
Iceberg (starts at 1).
@@ -411,15 +604,6 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
         return location;
     }
 
-    private Table getTable() {
-        return restCatalog.loadTable(icebergTableIdentifier);
-    }
-
-    private void dropTable() {
-        // set purge to false, because we don't need to delete the data files
-        restCatalog.dropTable(icebergTableIdentifier, false);
-    }
-
     private Table recreateTable(TableMetadata newMetadata) {
         try {
             dropTable();
@@ -429,6 +613,15 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
         }
     }
 
+    private Table getTable() {
+        return restCatalog.loadTable(icebergTableIdentifier);
+    }
+
+    private void dropTable() {
+        // set purge to false, because we don't need to delete the data files
+        restCatalog.dropTable(icebergTableIdentifier, false);
+    }
+
     // 
-------------------------------------------------------------------------------------
     // metadata updates
     // 
-------------------------------------------------------------------------------------
@@ -718,9 +911,6 @@ public class IcebergRestMetadataCommitter implements 
IcebergMetadataCommitter {
             return String.format(
                     "AddSnapshot(%s)",
                     ((MetadataUpdate.AddSnapshot) 
update).snapshot().snapshotId());
-        } else if (update instanceof MetadataUpdate.RemoveSnapshot) {
-            return String.format(
-                    "RemoveSnapshot(%s)", ((MetadataUpdate.RemoveSnapshot) 
update).snapshotId());
         } else if (update instanceof MetadataUpdate.SetSnapshotRef) {
             return String.format(
                     "SetSnapshotRef(%s, %s, %s)",
diff --git 
a/paimon-iceberg/src/test/java/org/apache/paimon/core/IcebergRowLineageCompatibilityTest.java
 
b/paimon-iceberg/src/test/java/org/apache/paimon/core/IcebergRowLineageCompatibilityTest.java
index cab2fe10d4..55a1eacb66 100644
--- 
a/paimon-iceberg/src/test/java/org/apache/paimon/core/IcebergRowLineageCompatibilityTest.java
+++ 
b/paimon-iceberg/src/test/java/org/apache/paimon/core/IcebergRowLineageCompatibilityTest.java
@@ -21,30 +21,57 @@ package org.apache.paimon.core;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.catalog.FileSystemCatalog;
 import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.disk.IOManagerImpl;
 import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.SeekableInputStream;
 import org.apache.paimon.fs.local.LocalFileIO;
 import org.apache.paimon.iceberg.IcebergOptions;
+import org.apache.paimon.iceberg.IcebergPathFactory;
+import org.apache.paimon.iceberg.manifest.IcebergManifestEntry;
+import org.apache.paimon.iceberg.manifest.IcebergManifestFile;
+import org.apache.paimon.iceberg.manifest.IcebergManifestFileMeta;
+import org.apache.paimon.iceberg.manifest.IcebergManifestList;
 import org.apache.paimon.iceberg.metadata.IcebergMetadata;
 import org.apache.paimon.iceberg.metadata.IcebergSnapshot;
 import org.apache.paimon.options.MemorySize;
 import org.apache.paimon.options.Options;
 import org.apache.paimon.schema.Schema;
 import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.FixedBucketRowKeyExtractor;
 import org.apache.paimon.table.sink.TableCommitImpl;
 import org.apache.paimon.table.sink.TableWriteImpl;
+import org.apache.paimon.types.DataField;
 import org.apache.paimon.types.DataType;
 import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowKind;
 import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.IOUtils;
 
+import org.apache.hadoop.conf.Configuration;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.ManifestFiles;
+import org.apache.iceberg.ManifestReader;
+import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.data.IcebergGenerics;
+import org.apache.iceberg.data.Record;
+import org.apache.iceberg.hadoop.HadoopCatalog;
+import org.apache.iceberg.io.CloseableIterable;
+import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.UUID;
 
@@ -207,6 +234,729 @@ public class IcebergRowLineageCompatibilityTest {
         assertThat(metadata.nextRowId()).isEqualTo(3L);
     }
 
+    @Test
+    public void testManifestListCarriesFirstRowIdColumn() throws Exception {
+        FileStoreTable table = createPaimonTable(defaultRowType(), 
formatVersionOptions(3), "avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(1, write.prepareCommit(false, 1));
+        write.close();
+        commit.close();
+
+        // the v3 manifest list must round-trip the first_row_id column
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        IcebergManifestList manifestList = IcebergManifestList.create(table, 
paths);
+        List<IcebergManifestFileMeta> metas =
+                manifestList.read(
+                        new Path(readIcebergMetadata(table, 
1).currentSnapshot().manifestList())
+                                .getName());
+        assertThat(metas).isNotEmpty();
+        // the v3 manifest list round-trips the first_row_id column; data 
manifests are assigned
+        // at manifest-list write time (this table's single commit starts at 
row id 0)
+        for (IcebergManifestFileMeta meta : metas) {
+            if (meta.content() == IcebergManifestFileMeta.Content.DATA) {
+                assertThat(meta.firstRowId()).isEqualTo(0L);
+            } else {
+                assertThat(meta.firstRowId()).isNull();
+            }
+        }
+    }
+
+    @Test
+    public void testManifestFirstRowIdAssignment() throws Exception {
+        FileStoreTable table = createPaimonTable(defaultRowType(), 
formatVersionOptions(3), "avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+
+        write.write(GenericRow.of(1, 10));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(1, write.prepareCommit(false, 1));
+
+        write.write(GenericRow.of(3, 30));
+        commit.commit(2, write.prepareCommit(false, 2));
+        write.close();
+        commit.close();
+
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        IcebergManifestList manifestList = IcebergManifestList.create(table, 
paths);
+        List<IcebergManifestFileMeta> metas =
+                manifestList.read(
+                        new Path(readIcebergMetadata(table, 
2).currentSnapshot().manifestList())
+                                .getName());
+
+        // every data manifest is assigned; watermark walks addedRowsCount in 
list order
+        long watermark = -1;
+        long totalAssigned = 0;
+        for (IcebergManifestFileMeta meta : metas) {
+            if (meta.content() == IcebergManifestFileMeta.Content.DATA) {
+                assertThat(meta.firstRowId()).isNotNull();
+                assertThat(meta.firstRowId()).isGreaterThan(watermark);
+                watermark = meta.firstRowId();
+                totalAssigned += meta.addedRowsCount();
+            } else {
+                assertThat(meta.firstRowId()).isNull();
+            }
+        }
+        // commit1 assigned rows [0,2), commit2 assigned [2,3): manifests 
carry 0 and 2
+        assertThat(
+                        metas.stream()
+                                .filter(m -> m.content() == 
IcebergManifestFileMeta.Content.DATA)
+                                .map(IcebergManifestFileMeta::firstRowId))
+                .containsExactlyInAnyOrder(0L, 2L);
+        assertThat(totalAssigned).isEqualTo(3L);
+    }
+
+    @Test
+    public void testReadsManifestListWrittenWithoutFirstRowIdColumn() throws 
Exception {
+        // A Layer-1 manifest list physically lacks column 520. The v3 reader 
must resolve
+        // the missing column to null (Avro schema resolution), not fail.
+        FileStoreTable table = createPaimonTable(defaultRowType(), 
formatVersionOptions(3), "avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(false, 1));
+        write.close();
+        commit.close();
+
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        String listName =
+                new Path(readIcebergMetadata(table, 
1).currentSnapshot().manifestList()).getName();
+
+        // rewrite the manifest list through the OLD (14-column) serializer to 
simulate Layer 1
+        FileStoreTable v2SchemaView =
+                
table.copy(Collections.singletonMap(IcebergOptions.FORMAT_VERSION.key(), "2"));
+        IcebergManifestList oldWriter = 
IcebergManifestList.create(v2SchemaView, paths);
+        IcebergManifestList newReader = IcebergManifestList.create(table, 
paths);
+        List<IcebergManifestFileMeta> metas = newReader.read(listName);
+        String rewritten = oldWriter.writeWithoutRolling(metas);
+        LocalFileIO.create().deleteQuietly(paths.toManifestListPath(listName));
+        LocalFileIO.create()
+                .rename(paths.toManifestListPath(rewritten), 
paths.toManifestListPath(listName));
+
+        // the v3 reader resolves the absent column to null for every meta
+        for (IcebergManifestFileMeta meta : newReader.read(listName)) {
+            assertThat(meta.firstRowId()).isNull();
+        }
+    }
+
+    @Test
+    public void testExpireKeepsManifestSharedAcrossRowIdAssignment() throws 
Exception {
+        // A manifest written before manifest-level lineage is re-listed with 
an assigned
+        // first_row_id but the same physical path. Expiring the 
pre-assignment manifest list
+        // must not delete the shared file, so liveness is decided by path, 
not value equality.
+        Map<String, String> options = formatVersionOptions(3);
+        options.put(CoreOptions.SNAPSHOT_NUM_RETAINED_MIN.key(), "1");
+        options.put(CoreOptions.SNAPSHOT_NUM_RETAINED_MAX.key(), "2");
+        FileStoreTable table = createPaimonTable(defaultRowType(), options, 
"avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(false, 1));
+
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        String listName =
+                new Path(readIcebergMetadata(table, 
1).currentSnapshot().manifestList()).getName();
+
+        // strip column 520 from snapshot 1's manifest list to simulate a 
pre-assignment writer
+        FileStoreTable v2SchemaView =
+                
table.copy(Collections.singletonMap(IcebergOptions.FORMAT_VERSION.key(), "2"));
+        IcebergManifestList oldWriter = 
IcebergManifestList.create(v2SchemaView, paths);
+        IcebergManifestList reader = IcebergManifestList.create(table, paths);
+        String rewritten = 
oldWriter.writeWithoutRolling(reader.read(listName));
+        LocalFileIO.create().deleteQuietly(paths.toManifestListPath(listName));
+        LocalFileIO.create()
+                .rename(paths.toManifestListPath(rewritten), 
paths.toManifestListPath(listName));
+        List<String> sharedManifestPaths = new ArrayList<>();
+        for (IcebergManifestFileMeta meta : reader.read(listName)) {
+            sharedManifestPaths.add(meta.manifestPath());
+        }
+        assertThat(sharedManifestPaths).isNotEmpty();
+
+        // commit 2 carries the manifest over, assigning first_row_id at the 
same path;
+        // commit 3 expires snapshot 1's manifest list against snapshot 2's
+        write.write(GenericRow.of(2, 20));
+        commit.commit(2, write.prepareCommit(false, 2));
+        write.write(GenericRow.of(3, 30));
+        commit.commit(3, write.prepareCommit(false, 3));
+        write.close();
+        commit.close();
+
+        IcebergMetadata metadata = readIcebergMetadata(table, 3);
+        assertThat(metadata.snapshots()).hasSize(2);
+        for (String manifestPath : sharedManifestPaths) {
+            assertThat(LocalFileIO.create().exists(new 
Path(manifestPath))).isTrue();
+        }
+        // every retained snapshot must stay readable end to end
+        IcebergManifestFile manifestFile = IcebergManifestFile.create(table, 
paths);
+        for (IcebergSnapshot snapshot : metadata.snapshots()) {
+            for (IcebergManifestFileMeta meta :
+                    reader.read(new Path(snapshot.manifestList()).getName())) {
+                assertThat(manifestFile.read(meta)).isNotEmpty();
+            }
+        }
+    }
+
+    @Test
+    public void testManifestEntriesCarryFirstRowIdColumn() throws Exception {
+        FileStoreTable table = createPaimonTable(defaultRowType(), 
formatVersionOptions(3), "avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(false, 1));
+        write.close();
+        commit.close();
+
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        IcebergManifestList manifestList = IcebergManifestList.create(table, 
paths);
+        IcebergManifestFile manifestFile = IcebergManifestFile.create(table, 
paths);
+        List<IcebergManifestFileMeta> metas =
+                manifestList.read(
+                        new Path(readIcebergMetadata(table, 
1).currentSnapshot().manifestList())
+                                .getName());
+        for (IcebergManifestFileMeta meta : metas) {
+            for (IcebergManifestEntry entry : manifestFile.read(meta)) {
+                // ADDED entries are unassigned by definition; the column must 
round-trip as null
+                assertThat(entry.file().firstRowId()).isNull();
+            }
+        }
+    }
+
+    @Test
+    public void testFirstRowIdStableAcrossManifestRewrite() throws Exception {
+        // A single-bucket table always rewrites its one-and-only file whole, 
so no entry ever
+        // survives a rewrite unchanged (the same-path invariant this test 
targets never fires).
+        // Use two buckets instead, and only touch one of them: the other 
bucket's file must
+        // then be carried, byte-for-byte unchanged, as an EXISTING entry into 
the manifest that
+        // gets rewritten because its sibling entry was removed.
+        RowType rowType = defaultRowType();
+        Map<String, String> customOptions = formatVersionOptions(3);
+        customOptions.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        customOptions.put(CoreOptions.DELETION_VECTOR_BITMAP64.key(), "true");
+        FileStoreTable table = createPkPaimonTable(rowType, customOptions);
+
+        int keyBucket0 = findKeyForBucket(table, 0);
+        int keyBucket1 = findKeyForBucket(table, 1);
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+
+        write.write(GenericRow.of(keyBucket0, 10));
+        write.write(GenericRow.of(keyBucket1, 20));
+        write.compact(BinaryRow.EMPTY_ROW, 0, true);
+        write.compact(BinaryRow.EMPTY_ROW, 1, true);
+        commit.commit(1, write.prepareCommit(true, 1));
+
+        // an append bundled with a full compaction is committed as two 
separate physical
+        // snapshots (append, then compact), so the Iceberg metadata id to 
inspect is whatever
+        // snapshot id is actually latest now, not the external commit 
identifier used above
+        Map<String, Long> idsBefore =
+                effectiveFileFirstRowIds(table, 
table.snapshotManager().latestSnapshotId());
+        assertThat(idsBefore).isNotEmpty();
+
+        // delete the bucket-0 key and compact only bucket 0: bucket 1's file 
is left entirely
+        // untouched, so the shared manifest is rewritten (bucket 0's entry 
removed) while
+        // bucket 1's entry must survive, under the same file path, as a 
materialized EXISTING
+        // entry
+        write.write(GenericRow.ofKind(RowKind.DELETE, keyBucket0, 10));
+        write.compact(BinaryRow.EMPTY_ROW, 0, true);
+        commit.commit(2, write.prepareCommit(true, 2));
+        write.close();
+        commit.close();
+
+        Map<String, Long> idsAfter =
+                effectiveFileFirstRowIds(table, 
table.snapshotManager().latestSnapshotId());
+        assertThat(idsAfter).isNotEmpty();
+        boolean checkedAtLeastOneSurvivor = false;
+        for (Map.Entry<String, Long> e : idsAfter.entrySet()) {
+            Long before = idsBefore.get(e.getKey());
+            if (before != null) {
+                checkedAtLeastOneSurvivor = true;
+                // a file carried across the rewrite keeps its effective first 
row id
+                assertThat(e.getValue()).as("file %s", 
e.getKey()).isEqualTo(before);
+            }
+        }
+        assertThat(checkedAtLeastOneSurvivor)
+                .as("expected bucket 1's file to survive the manifest rewrite")
+                .isTrue();
+    }
+
+    @Test
+    public void testDeletedEntriesDoNotShiftInheritedIds() throws Exception {
+        // A legacy (pre-assignment) manifest holding a DELETED entry before a 
live one: GA
+        // readers skip DELETED entries when assigning inherited ids, so the 
live entry
+        // inherits the manifest's first_row_id itself, NOT shifted by the 
deleted rows.
+        RowType rowType = defaultRowType();
+        Map<String, String> customOptions = formatVersionOptions(3);
+        customOptions.put(CoreOptions.DELETION_VECTORS_ENABLED.key(), "true");
+        customOptions.put(CoreOptions.DELETION_VECTOR_BITMAP64.key(), "true");
+        FileStoreTable table = createPkPaimonTable(rowType, customOptions);
+
+        int keyBucket0 = findKeyForBucket(table, 0);
+        int keyBucket1 = findKeyForBucket(table, 1);
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+
+        write.write(GenericRow.of(keyBucket0, 10));
+        write.write(GenericRow.of(keyBucket1, 20));
+        write.compact(BinaryRow.EMPTY_ROW, 0, true);
+        write.compact(BinaryRow.EMPTY_ROW, 1, true);
+        commit.commit(1, write.prepareCommit(true, 1));
+
+        // rewrite the shared manifest: bucket 0's entry becomes DELETED, 
bucket 1's is
+        // carried as EXISTING behind it
+        write.write(GenericRow.ofKind(RowKind.DELETE, keyBucket0, 10));
+        write.compact(BinaryRow.EMPTY_ROW, 0, true);
+        commit.commit(2, write.prepareCommit(true, 2));
+
+        long rewriteSnapshot = table.snapshotManager().latestSnapshotId();
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        IcebergManifestList listReader = IcebergManifestList.create(table, 
paths);
+        IcebergManifestFile entryReader = IcebergManifestFile.create(table, 
paths);
+        String listName =
+                new Path(
+                                readIcebergMetadata(table, rewriteSnapshot)
+                                        .currentSnapshot()
+                                        .manifestList())
+                        .getName();
+
+        // strip lineage from the manifest with the DELETED entry and from the 
list,
+        // simulating metadata written before manifest-level assignment existed
+        FileStoreTable v2SchemaView =
+                
table.copy(Collections.singletonMap(IcebergOptions.FORMAT_VERSION.key(), "2"));
+        IcebergManifestFile oldEntryWriter = 
IcebergManifestFile.create(v2SchemaView, paths);
+        String strippedPath = null;
+        for (IcebergManifestFileMeta meta : listReader.read(listName)) {
+            if (meta.content() != IcebergManifestFileMeta.Content.DATA
+                    || meta.deletedFilesCount() == 0) {
+                continue;
+            }
+            strippedPath = meta.manifestPath();
+            List<IcebergManifestEntry> entries = entryReader.read(meta);
+            
assertThat(entries.get(0).status()).isEqualTo(IcebergManifestEntry.Status.DELETED);
+            List<IcebergManifestFileMeta> rewritten =
+                    oldEntryWriter.rollingWrite(
+                            entries.iterator(), meta.sequenceNumber(), 
meta.content());
+            Path target = new Path(meta.manifestPath());
+            LocalFileIO.create().deleteQuietly(target);
+            LocalFileIO.create().rename(new 
Path(rewritten.get(0).manifestPath()), target);
+        }
+        assertThat(strippedPath).isNotNull();
+        final String stripped = strippedPath;
+        IcebergManifestList oldListWriter = 
IcebergManifestList.create(v2SchemaView, paths);
+        String rewrittenList = 
oldListWriter.writeWithoutRolling(listReader.read(listName));
+        LocalFileIO.create().deleteQuietly(paths.toManifestListPath(listName));
+        LocalFileIO.create()
+                .rename(
+                        paths.toManifestListPath(rewrittenList),
+                        paths.toManifestListPath(listName));
+
+        // next commit re-assigns the stripped manifest at list level
+        write.write(GenericRow.of(keyBucket0, 11));
+        commit.commit(3, write.prepareCommit(false, 3));
+        write.close();
+        commit.close();
+
+        long latest = table.snapshotManager().latestSnapshotId();
+        Long assignedFirst = null;
+        long upperBoundAdvance = 0;
+        String latestList =
+                new Path(readIcebergMetadata(table, 
latest).currentSnapshot().manifestList())
+                        .getName();
+        for (IcebergManifestFileMeta meta : listReader.read(latestList)) {
+            if (meta.manifestPath().equals(strippedPath)) {
+                assignedFirst = meta.firstRowId();
+                upperBoundAdvance = meta.addedRowsCount() + 
meta.existingRowsCount();
+            }
+        }
+        assertThat(assignedFirst).isNotNull();
+        // the manifest reserves only its ADDED+EXISTING rows; the DELETED 
entry is excluded
+        assertThat(upperBoundAdvance).isEqualTo(1L);
+
+        // the live entry inherits the manifest's own first_row_id, not 
shifted by the
+        // 1-row DELETED entry sitting before it
+        Map<String, Long> effective = effectiveFileFirstRowIds(table, latest);
+        boolean sawSurvivor = false;
+        for (IcebergManifestEntry entry :
+                entryReader.read(
+                        listReader.read(latestList).stream()
+                                .filter(m -> m.manifestPath().equals(stripped))
+                                .findFirst()
+                                .get())) {
+            if (entry.isLive()) {
+                sawSurvivor = true;
+                assertThat(entry.file().firstRowId()).isNull();
+                
assertThat(effective.get(entry.file().filePath())).isEqualTo(assignedFirst);
+            }
+        }
+        assertThat(sawSurvivor).isTrue();
+
+        // GA reader cross-check: Iceberg 1.11 resolves the same id for the 
live file
+        if (GA_ROW_LINEAGE_READER) {
+            HadoopCatalog icebergCatalog =
+                    new HadoopCatalog(new Configuration(), tempDir.toString());
+            Table icebergTable = 
icebergCatalog.loadTable(TableIdentifier.of("mydb2.db", "t"));
+            boolean checked = false;
+            for (ManifestFile manifest :
+                    
icebergTable.currentSnapshot().dataManifests(icebergTable.io())) {
+                if (!manifest.path().equals(strippedPath)) {
+                    continue;
+                }
+                try (ManifestReader<DataFile> gaReader =
+                        ManifestFiles.read(manifest, icebergTable.io(), 
icebergTable.specs())) {
+                    for (DataFile file : gaReader) {
+                        checked = true;
+                        
assertThat(dataFileFirstRowId(file)).isEqualTo(assignedFirst);
+                    }
+                }
+            }
+            assertThat(checked).isTrue();
+        }
+    }
+
+    @Test
+    public void testGaReaderSeesAssignedManifests() throws Exception {
+        assumeGaRowLineageReader();
+        FileStoreTable table = createPaimonTable(defaultRowType(), 
formatVersionOptions(3), "avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(1, write.prepareCommit(false, 1));
+        write.close();
+        commit.close();
+
+        HadoopCatalog icebergCatalog = new HadoopCatalog(new Configuration(), 
tempDir.toString());
+        Table icebergTable = 
icebergCatalog.loadTable(TableIdentifier.of("mydb.db", "t"));
+        assertThat(icebergTable.currentSnapshot().firstRowId()).isEqualTo(0L);
+        for (ManifestFile manifest :
+                
icebergTable.currentSnapshot().dataManifests(icebergTable.io())) {
+            assertThat(manifestFirstRowId(manifest)).isNotNull();
+        }
+    }
+
+    @Test
+    public void testLayer1TableUpgradesOnNextCommit() throws Exception {
+        FileStoreTable table = createPaimonTable(defaultRowType(), 
formatVersionOptions(3), "avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(false, 1));
+
+        // simulate a Layer-1-written manifest list: strip the assigned 
first_row_id
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        IcebergManifestList manifestList = IcebergManifestList.create(table, 
paths);
+        String listName =
+                new Path(readIcebergMetadata(table, 
1).currentSnapshot().manifestList()).getName();
+        List<IcebergManifestFileMeta> stripped = new ArrayList<>();
+        for (IcebergManifestFileMeta meta : manifestList.read(listName)) {
+            stripped.add(
+                    new IcebergManifestFileMeta(
+                            meta.manifestPath(),
+                            meta.manifestLength(),
+                            meta.partitionSpecId(),
+                            meta.content(),
+                            meta.sequenceNumber(),
+                            meta.minSequenceNumber(),
+                            meta.addedSnapshotId(),
+                            meta.addedFilesCount(),
+                            meta.existingFilesCount(),
+                            meta.deletedFilesCount(),
+                            meta.addedRowsCount(),
+                            meta.existingRowsCount(),
+                            meta.deletedRowsCount(),
+                            meta.partitions(),
+                            null));
+        }
+        // overwrite the manifest list in place with unassigned metas
+        LocalFileIO.create().deleteQuietly(paths.toManifestListPath(listName));
+        // writeWithoutRolling creates a new file; rename it over the original
+        String rewritten = manifestList.writeWithoutRolling(stripped);
+        LocalFileIO.create()
+                .rename(paths.toManifestListPath(rewritten), 
paths.toManifestListPath(listName));
+        assertThat(manifestList.read(listName).get(0).firstRowId()).isNull();
+        // the stripped Layer-1 manifest has no existing/deleted entries, so 
its addedRowsCount()
+        // is an exact count of the legacy rows it carries and still needs a 
real id for
+        long legacyManifestRows = stripped.get(0).addedRowsCount();
+        assertThat(legacyManifestRows).isEqualTo(1L);
+
+        // next commit re-lists carried-over manifests: unassigned metas get 
assigned now
+        write.write(GenericRow.of(2, 20));
+        commit.commit(2, write.prepareCommit(false, 2));
+
+        IcebergMetadata metadataAfterCommit2 = readIcebergMetadata(table, 2);
+        IcebergSnapshot snapshotAfterCommit2 = 
metadataAfterCommit2.currentSnapshot();
+        List<IcebergManifestFileMeta> dataManifestsAfterCommit2 = new 
ArrayList<>();
+        for (IcebergManifestFileMeta meta :
+                manifestList.read(new 
Path(snapshotAfterCommit2.manifestList()).getName())) {
+            if (meta.content() == IcebergManifestFileMeta.Content.DATA) {
+                assertThat(meta.firstRowId()).isNotNull();
+                dataManifestsAfterCommit2.add(meta);
+            }
+        }
+        // the re-assigned legacy manifest (M1) and this commit's freshly 
written manifest (M2)
+        // stay separate: only 2 data manifests, well under the 
metadata-compaction threshold
+        assertThat(dataManifestsAfterCommit2).hasSize(2);
+
+        // this is the corruption scenario from the review: 
added-rows/next-row-id must count
+        // the legacy manifest's re-assigned rows in addition to this commit's 
own new rows, not
+        // just this commit's `metrics.addedRecords` (which is only the new 
row)
+        long newRowsThisCommit = 1L;
+        long expectedAddedRows = legacyManifestRows + newRowsThisCommit;
+        assertThat(snapshotAfterCommit2.addedRows())
+                .as("snapshot added-rows must include the re-assigned legacy 
manifest's rows")
+                .isEqualTo(expectedAddedRows);
+        assertThat(metadataAfterCommit2.nextRowId())
+                .as("next-row-id must equal first-row-id plus the true 
assigned-rows total")
+                .isEqualTo(snapshotAfterCommit2.firstRowId() + 
snapshotAfterCommit2.addedRows());
+        for (IcebergManifestFileMeta meta : dataManifestsAfterCommit2) {
+            assertThat(metadataAfterCommit2.nextRowId())
+                    .as(
+                            "next-row-id must be at or beyond every manifest's 
assigned range "
+                                    + "(manifest %s)",
+                            meta.manifestPath())
+                    .isGreaterThanOrEqualTo(meta.firstRowId() + 
meta.addedRowsCount());
+        }
+
+        // a third commit: its first-row-id must continue exactly where commit 
2 left off, and no
+        // manifest's assigned id range may overlap another's (i.e. no file 
gets a duplicate
+        // effective first-row-id)
+        write.write(GenericRow.of(3, 30));
+        commit.commit(3, write.prepareCommit(false, 3));
+        write.close();
+        commit.close();
+
+        IcebergMetadata metadataAfterCommit3 = readIcebergMetadata(table, 3);
+        IcebergSnapshot snapshotAfterCommit3 = 
metadataAfterCommit3.currentSnapshot();
+        
assertThat(snapshotAfterCommit3.firstRowId()).isEqualTo(metadataAfterCommit2.nextRowId());
+
+        Map<String, Long> effectiveIdsAfterCommit3 = 
effectiveFileFirstRowIds(table, 3);
+        assertThat(effectiveIdsAfterCommit3).hasSize(3);
+        assertThat(new HashSet<>(effectiveIdsAfterCommit3.values()))
+                .as("no two files may share an effective first-row-id")
+                .hasSameSizeAs(effectiveIdsAfterCommit3.values());
+    }
+
+    @Test
+    public void testRowTrackingTableUsesSyntheticIds() throws Exception {
+        Map<String, String> options = formatVersionOptions(3);
+        options.put(CoreOptions.ROW_TRACKING_ENABLED.key(), "true");
+        FileStoreTable table = createPaimonTable(defaultRowType(), options, 
"avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(1, write.prepareCommit(false, 1));
+        write.close();
+        commit.close();
+
+        // documented independence: assignment behaves exactly as for any 
other table
+        IcebergMetadata metadata = readIcebergMetadata(table, 1);
+        assertThat(metadata.nextRowId()).isEqualTo(2L);
+        assertThat(effectiveFileFirstRowIds(table, 
1).values()).containsExactly(0L);
+    }
+
+    @Test
+    public void testCompactMetadataIfNeededMaterializesRowLineageUnderV3() 
throws Exception {
+        // exercises the `compactMetadataIfNeeded` manifest-metadata-merge 
call site under v3,
+        // which no existing test hits (all format-version-3 tests leave 
COMPACT_MIN_FILE_NUM
+        // at its default of 10, and all tests that force compaction stay on 
format version 2).
+        RowType rowType = defaultRowType();
+        Map<String, String> customOptions = formatVersionOptions(3);
+        customOptions.put(IcebergOptions.COMPACT_MIN_FILE_NUM.key(), "2");
+        customOptions.put(IcebergOptions.COMPACT_MAX_FILE_NUM.key(), "2");
+        // large enough that manifests are never excluded as "already big 
enough", so the
+        // min/max file-count thresholds above are what actually triggers the 
merge
+        customOptions.put(CoreOptions.MANIFEST_TARGET_FILE_SIZE.key(), "64 
mb");
+        FileStoreTable table = createPaimonTable(rowType, customOptions, 
"avro");
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+
+        // commit 1: single manifest M1 (candidates=1 < COMPACT_MIN_FILE_NUM, 
no compaction)
+        write.write(GenericRow.of(1, 10));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(1, write.prepareCommit(false, 1));
+        Map<String, Long> idsAfterCommit1 =
+                effectiveFileFirstRowIds(table, 
table.snapshotManager().latestSnapshotId());
+        assertThat(idsAfterCommit1).hasSize(1);
+
+        // commit 2: M1 (already assigned) + fresh M2 => 2 candidates, meets 
both thresholds,
+        // manifest metadata compaction merges them into a single manifest 
this same commit
+        write.write(GenericRow.of(3, 30));
+        commit.commit(2, write.prepareCommit(false, 2));
+        assertThat(dataManifestCount(table, 
table.snapshotManager().latestSnapshotId()))
+                .as("commit 2 should have merged M1+M2 into a single data 
manifest")
+                .isEqualTo(1);
+        Map<String, Long> idsAfterCommit2 =
+                effectiveFileFirstRowIds(table, 
table.snapshotManager().latestSnapshotId());
+        assertThat(idsAfterCommit2).hasSize(2);
+        for (Map.Entry<String, Long> e : idsAfterCommit1.entrySet()) {
+            // every file's effective first-row-id survives the 
metadata-compaction commit
+            // unchanged, whether it was inherited or already explicit before 
the merge
+            assertThat(idsAfterCommit2)
+                    .as("file %s", e.getKey())
+                    .containsEntry(e.getKey(), e.getValue());
+        }
+        assertMergedManifestExistingEntriesHaveExplicitFirstRowId(table);
+
+        // commit 3: merges again, this time the base manifest already 
contains an
+        // EXISTING entry with an explicit field 142 from commit 2's merge 
(file 1/2) sitting
+        // alongside an ADDED entry with inherited-only field 142 (file from 
commit 2) -- this
+        // is the "explicit-142 passthrough" branch of materializeFirstRowIds 
that no other
+        // test reaches
+        write.write(GenericRow.of(4, 40));
+        commit.commit(3, write.prepareCommit(false, 3));
+        write.close();
+        commit.close();
+        assertThat(dataManifestCount(table, 
table.snapshotManager().latestSnapshotId()))
+                .as("commit 3 should merge again into a single data manifest")
+                .isEqualTo(1);
+        Map<String, Long> idsAfterCommit3 =
+                effectiveFileFirstRowIds(table, 
table.snapshotManager().latestSnapshotId());
+        assertThat(idsAfterCommit3).hasSize(3);
+        for (Map.Entry<String, Long> e : idsAfterCommit2.entrySet()) {
+            assertThat(idsAfterCommit3)
+                    .as("file %s", e.getKey())
+                    .containsEntry(e.getKey(), e.getValue());
+        }
+        assertMergedManifestExistingEntriesHaveExplicitFirstRowId(table);
+    }
+
+    @Test
+    public void testGaReaderResolvesPerFileRowLineage() throws Exception {
+        assumeGaRowLineageReader();
+        // standard two-commit 2+1-row setup (matches 
testNextRowIdAdvancesAcrossCommits):
+        // commit 1's file gets effective first-row-id 0, commit 2's file gets 
2
+        FileStoreTable table = createPaimonTable(defaultRowType(), 
formatVersionOptions(3), "avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(1, write.prepareCommit(false, 1));
+        write.write(GenericRow.of(3, 30));
+        commit.commit(2, write.prepareCommit(false, 2));
+        write.close();
+        commit.close();
+
+        HadoopCatalog icebergCatalog = new HadoopCatalog(new Configuration(), 
tempDir.toString());
+        Table icebergTable = 
icebergCatalog.loadTable(TableIdentifier.of("mydb.db", "t"));
+
+        // Attempt 1: resolve a per-row `_row_id` value through iceberg-data's 
GA generics
+        // reader. `select(...)` does not throw, but the requested metadata 
column is silently
+        // dropped from the projected schema: 
GenericReader/InternalRecordWrapper in
+        // iceberg-data 1.11.0 have no wiring for MetadataColumns.ROW_ID 
(unlike _pos/_file/
+        // _deleted/_spec_id, which the same reader stack does resolve), so 
every record's
+        // "_row_id" field comes back null instead of the assigned/inherited 
value. This is
+        // verified here rather than assumed: if a future Iceberg release adds 
real support,
+        // this loop will start observing non-null values and the assertion 
below must change.
+        boolean anyRowIdResolved = false;
+        try (CloseableIterable<Record> records =
+                IcebergGenerics.read(icebergTable).select("k", "v", 
"_row_id").build()) {
+            for (Record record : records) {
+                if (record.getField("_row_id") != null) {
+                    anyRowIdResolved = true;
+                }
+            }
+        }
+        assertThat(anyRowIdResolved)
+                .as(
+                        "iceberg-data 1.11.0 GA generics do not resolve the 
_row_id metadata "
+                                + "column; if this starts failing, generics 
gained support and "
+                                + "the fallback below can be 
simplified/removed")
+                .isFalse();
+
+        // Fallback: GA's ManifestFiles/DataFile APIs DO resolve the per-file 
first_row_id
+        // (including inheritance from the manifest-level value), so assert 
actual values,
+        // not just non-nullity.
+        List<Long> resolvedFirstRowIds = new ArrayList<>();
+        for (ManifestFile manifest :
+                
icebergTable.currentSnapshot().dataManifests(icebergTable.io())) {
+            try (ManifestReader<DataFile> reader =
+                    ManifestFiles.read(manifest, icebergTable.io(), 
icebergTable.specs())) {
+                for (DataFile file : reader) {
+                    resolvedFirstRowIds.add(dataFileFirstRowId(file));
+                }
+            }
+        }
+        assertThat(resolvedFirstRowIds).containsExactlyInAnyOrder(0L, 2L);
+    }
+
+    @Test
+    public void testV2ManifestListSchemaHasNoFirstRowIdColumn() throws 
Exception {
+        // pins the v2 manifest-list shape: 14 columns, no first_row_id 
anywhere, so a future
+        // change to the v3 schema construction cannot silently leak into v2's 
byte-identical
+        // output
+        
assertThat(IcebergManifestFileMeta.schema(false).getFieldCount()).isEqualTo(14);
+        
assertThat(IcebergManifestFileMeta.schema(false).getFields().stream().map(DataField::name))
+                .doesNotContain("first_row_id");
+        
assertThat(IcebergManifestFileMeta.schema(true).getFieldCount()).isEqualTo(14);
+        
assertThat(IcebergManifestFileMeta.schema(true).getFields().stream().map(DataField::name))
+                .doesNotContain("first_row_id");
+
+        FileStoreTable table = createPaimonTable(defaultRowType(), 
formatVersionOptions(2), "avro");
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write =
+                table.newWrite(commitUser)
+                        .withIOManager(new IOManagerImpl(tempDir.toString() + 
"/tmp"));
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(false, 1));
+        write.close();
+        commit.close();
+
+        // the written manifest-list file's raw bytes must not contain 
"first_row_id" anywhere;
+        // Avro embeds the writer schema as JSON in the file header, so a 
plain byte-scan of the
+        // whole file is a valid (and stronger-than-parsed) check of the 
physical shape
+        Path manifestListPath =
+                new Path(readIcebergMetadata(table, 
1).currentSnapshot().manifestList());
+        byte[] bytes;
+        try (SeekableInputStream in = 
table.fileIO().newInputStream(manifestListPath)) {
+            bytes = IOUtils.readFully(in, false);
+        }
+        String content = new String(bytes, StandardCharsets.ISO_8859_1);
+        assertThat(content).doesNotContain("first_row_id");
+    }
+
     // ------------------------------------------------------------------------
     //  helpers
     // ------------------------------------------------------------------------
@@ -330,6 +1080,135 @@ public class IcebergRowLineageCompatibilityTest {
         }
     }
 
+    private FileStoreTable createPkPaimonTable(RowType rowType, Map<String, 
String> customOptions)
+            throws Exception {
+        LocalFileIO fileIO = LocalFileIO.create();
+        Path path = new Path(tempDir.toString());
+        Options options = new Options(customOptions);
+        // two fixed buckets so a manifest rewrite can leave one bucket's file 
untouched
+        // (see testFirstRowIdStableAcrossManifestRewrite)
+        options.set(CoreOptions.BUCKET, 2);
+        options.set(
+                IcebergOptions.METADATA_ICEBERG_STORAGE, 
IcebergOptions.StorageType.TABLE_LOCATION);
+        options.set(CoreOptions.FILE_FORMAT, "avro");
+        Schema schema =
+                new Schema(
+                        rowType.getFields(),
+                        Collections.<String>emptyList(),
+                        Collections.singletonList("k"),
+                        options.toMap(),
+                        "");
+        try (FileSystemCatalog paimonCatalog = new FileSystemCatalog(fileIO, 
path)) {
+            paimonCatalog.createDatabase("mydb2", false);
+            Identifier id = Identifier.create("mydb2", "t");
+            paimonCatalog.createTable(id, schema, false);
+            return (FileStoreTable) paimonCatalog.getTable(id);
+        }
+    }
+
+    /** Finds the smallest positive key whose fixed-bucket hash lands in 
{@code targetBucket}. */
+    private int findKeyForBucket(FileStoreTable table, int targetBucket) {
+        FixedBucketRowKeyExtractor extractor = new 
FixedBucketRowKeyExtractor(table.schema());
+        for (int k = 1; k < 10_000; k++) {
+            extractor.setRecord(GenericRow.of(k, 0));
+            if (extractor.bucket() == targetBucket) {
+                return k;
+            }
+        }
+        throw new IllegalStateException("No key found for bucket " + 
targetBucket);
+    }
+
+    /** Effective per-file first row id: explicit field 142, or inherited per 
the spec rules. */
+    private Map<String, Long> effectiveFileFirstRowIds(FileStoreTable table, 
long snapshotId)
+            throws Exception {
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        IcebergManifestList manifestList = IcebergManifestList.create(table, 
paths);
+        IcebergManifestFile manifestFile = IcebergManifestFile.create(table, 
paths);
+        Map<String, Long> result = new HashMap<>();
+        for (IcebergManifestFileMeta meta :
+                manifestList.read(
+                        new Path(
+                                        readIcebergMetadata(table, snapshotId)
+                                                .currentSnapshot()
+                                                .manifestList())
+                                .getName())) {
+            if (meta.content() != IcebergManifestFileMeta.Content.DATA) {
+                continue;
+            }
+            long watermark = meta.firstRowId() == null ? -1 : 
meta.firstRowId();
+            for (IcebergManifestEntry entry : manifestFile.read(meta)) {
+                if (entry.status() == IcebergManifestEntry.Status.DELETED) {
+                    // GA readers never assign ids to DELETED entries; they 
must not
+                    // advance the inheritance walk either
+                    continue;
+                }
+                long effective;
+                if (entry.file().firstRowId() != null) {
+                    effective = entry.file().firstRowId();
+                } else {
+                    effective = watermark;
+                    watermark += entry.file().recordCount();
+                }
+                if (entry.isLive()) {
+                    result.put(entry.file().filePath(), effective);
+                }
+            }
+        }
+        return result;
+    }
+
+    /** Number of DATA-content manifests referenced by the given snapshot's 
manifest list. */
+    private int dataManifestCount(FileStoreTable table, long snapshotId) 
throws Exception {
+        return dataManifestMetas(table, snapshotId).size();
+    }
+
+    private List<IcebergManifestFileMeta> dataManifestMetas(FileStoreTable 
table, long snapshotId)
+            throws Exception {
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        IcebergManifestList manifestList = IcebergManifestList.create(table, 
paths);
+        List<IcebergManifestFileMeta> result = new ArrayList<>();
+        for (IcebergManifestFileMeta meta :
+                manifestList.read(
+                        new Path(
+                                        readIcebergMetadata(table, snapshotId)
+                                                .currentSnapshot()
+                                                .manifestList())
+                                .getName())) {
+            if (meta.content() == IcebergManifestFileMeta.Content.DATA) {
+                result.add(meta);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * After a manifest-metadata-compaction merge, every EXISTING entry in the 
merged manifest(s)
+     * for the table's current snapshot must carry an explicit (non-null) 
field 142: EXISTING
+     * entries are, by definition, carried-over/rewritten entries, so their 
first_row_id must have
+     * been materialized by {@code materializeFirstRowIds} rather than left to 
be inherited from the
+     * (now-merged-away) original manifest.
+     */
+    private void 
assertMergedManifestExistingEntriesHaveExplicitFirstRowId(FileStoreTable table)
+            throws Exception {
+        IcebergPathFactory paths = new IcebergPathFactory(new 
Path(table.location(), "metadata"));
+        IcebergManifestFile manifestFile = IcebergManifestFile.create(table, 
paths);
+        long snapshotId = table.snapshotManager().latestSnapshotId();
+        boolean checkedAtLeastOneExistingEntry = false;
+        for (IcebergManifestFileMeta meta : dataManifestMetas(table, 
snapshotId)) {
+            for (IcebergManifestEntry entry : manifestFile.read(meta)) {
+                if (entry.status() == IcebergManifestEntry.Status.EXISTING) {
+                    checkedAtLeastOneExistingEntry = true;
+                    assertThat(entry.file().firstRowId())
+                            .as("EXISTING entry for file %s", 
entry.file().filePath())
+                            .isNotNull();
+                }
+            }
+        }
+        assertThat(checkedAtLeastOneExistingEntry)
+                .as("expected at least one materialized EXISTING entry after 
the merge")
+                .isTrue();
+    }
+
     private Path metadataPath(FileStoreTable table, long snapshotId) {
         return new Path(table.location(), 
String.format("metadata/v%d.metadata.json", snapshotId));
     }
@@ -341,4 +1220,42 @@ public class IcebergRowLineageCompatibilityTest {
     private String readMetadataJson(FileStoreTable table, long snapshotId) 
throws Exception {
         return LocalFileIO.create().readFileUtf8(metadataPath(table, 
snapshotId));
     }
+
+    /**
+     * Iceberg exposes per-manifest / per-file {@code firstRowId()} only from 
the GA row-lineage
+     * line (1.10+). The module compiles against 1.8.1 by default, so GA 
reader assertions look the
+     * method up reflectively and the tests skip when the API is absent (run 
with -Piceberg-ga).
+     */
+    private static final boolean GA_ROW_LINEAGE_READER = 
detectGaRowLineageReader();
+
+    private static boolean detectGaRowLineageReader() {
+        try {
+            ManifestFile.class.getMethod("firstRowId");
+            return true;
+        } catch (NoSuchMethodException e) {
+            return false;
+        }
+    }
+
+    private static void assumeGaRowLineageReader() {
+        Assumptions.assumeTrue(
+                GA_ROW_LINEAGE_READER,
+                "Iceberg on the test classpath predates GA row lineage; run 
with -Piceberg-ga");
+    }
+
+    private static Long manifestFirstRowId(ManifestFile manifest) {
+        try {
+            return (Long) 
ManifestFile.class.getMethod("firstRowId").invoke(manifest);
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    private static Long dataFileFirstRowId(DataFile file) {
+        try {
+            return (Long) DataFile.class.getMethod("firstRowId").invoke(file);
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException(e);
+        }
+    }
 }
diff --git 
a/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergMetadataTest.java
 
b/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergMetadataTest.java
index a6205dc107..79a2909df1 100644
--- 
a/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergMetadataTest.java
+++ 
b/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergMetadataTest.java
@@ -389,10 +389,18 @@ class IcebergMetadataTest {
     void testFormatVersionV3Table() throws Exception {
         // Create a v3 format version Iceberg table
         Table icebergTable = createIcebergTableV3("v3_snapshot_table");
-        TableMetadata base = ((HasTableOperations) 
icebergTable).operations().current();
-        ((HasTableOperations) icebergTable)
-                .operations()
-                .commit(base, 
TableMetadata.buildFrom(base).enableRowLineage().build());
+        try {
+            // pre-GA Iceberg (< 1.10) required opting into v3 row lineage; 
the builder
+            // method was removed in GA, where row lineage is always on for v3
+            java.lang.reflect.Method enableRowLineage =
+                    TableMetadata.Builder.class.getMethod("enableRowLineage");
+            TableMetadata base = ((HasTableOperations) 
icebergTable).operations().current();
+            TableMetadata.Builder builder = TableMetadata.buildFrom(base);
+            enableRowLineage.invoke(builder);
+            ((HasTableOperations) icebergTable).operations().commit(base, 
builder.build());
+        } catch (NoSuchMethodException e) {
+            // GA Iceberg: nothing to opt into
+        }
 
         // Read metadata using Paimon's IcebergMetadata
         IcebergMetadata paimonIcebergMetadata = 
readIcebergMetadata(icebergTable);
diff --git 
a/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitterTest.java
 
b/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitterTest.java
index 15782acdc5..b20aa47aa8 100644
--- 
a/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitterTest.java
+++ 
b/paimon-iceberg/src/test/java/org/apache/paimon/iceberg/IcebergRestMetadataCommitterTest.java
@@ -28,6 +28,8 @@ import org.apache.paimon.data.GenericRow;
 import org.apache.paimon.fs.FileIO;
 import org.apache.paimon.fs.Path;
 import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.iceberg.manifest.IcebergManifestFileMeta;
+import org.apache.paimon.iceberg.manifest.IcebergManifestList;
 import org.apache.paimon.iceberg.metadata.IcebergMetadata;
 import org.apache.paimon.iceberg.metadata.IcebergSnapshot;
 import org.apache.paimon.options.MemorySize;
@@ -43,9 +45,11 @@ import org.apache.paimon.types.RowType;
 
 import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.ManifestFile;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableUtil;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
@@ -54,6 +58,7 @@ import org.apache.iceberg.data.Record;
 import org.apache.iceberg.hadoop.HadoopCatalog;
 import org.apache.iceberg.io.CloseableIterable;
 import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.rest.Endpoint;
 import org.apache.iceberg.rest.RESTCatalog;
 import org.apache.iceberg.rest.RESTCatalogServer;
 import org.apache.iceberg.rest.RESTServerExtension;
@@ -68,9 +73,11 @@ import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.ThreadLocalRandom;
 import java.util.function.BiFunction;
@@ -79,6 +86,7 @@ import java.util.stream.Collectors;
 
 import static 
org.apache.paimon.iceberg.IcebergCommitCallback.catalogTableMetadataPath;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /** Test for {@link IcebergRestMetadataCommitter}. */
 public class IcebergRestMetadataCommitterTest {
@@ -1482,6 +1490,277 @@ public class IcebergRestMetadataCommitterTest {
         // Known Layer 1 limitation (documented in the spec): the server-side 
next-row-id
         // watermark restarts on recreation and stays behind the local 
metadata; commits
         // must keep succeeding regardless (validation is first-row-id >= 
next-row-id).
+
+        // reader-visible lineage from the REST catalog matches the file-based 
mirror:
+        // snapshot first-row-id and manifest assignments come from local 
metadata, never
+        // from the server's table-level watermark. Compare by value, not just 
non-nullity,
+        // against the locally-written IcebergMetadata + manifest list under 
the paimon
+        // table's own metadata dir (catalogTableMetadataPath), which is the 
source of truth
+        // the REST-registered table's metadata-location actually points at.
+        long latestSnapshotId = table.snapshotManager().latestSnapshotId();
+        IcebergMetadata localMetadata =
+                IcebergMetadata.fromPath(
+                        table.fileIO(),
+                        new Path(
+                                catalogTableMetadataPath(table),
+                                String.format("v%d.metadata.json", 
latestSnapshotId)));
+        IcebergSnapshot localSnapshot = localMetadata.currentSnapshot();
+        assertThat(localSnapshot.firstRowId()).isNotNull();
+
+        IcebergPathFactory pathFactory = new 
IcebergPathFactory(catalogTableMetadataPath(table));
+        IcebergManifestList localManifestList = 
IcebergManifestList.create(table, pathFactory);
+        List<Long> localDataManifestFirstRowIds =
+                localManifestList.read(new 
Path(localSnapshot.manifestList()).getName()).stream()
+                        .filter(m -> m.content() == 
IcebergManifestFileMeta.Content.DATA)
+                        .map(IcebergManifestFileMeta::firstRowId)
+                        .collect(Collectors.toList());
+        
assertThat(localDataManifestFirstRowIds).isNotEmpty().doesNotContainNull();
+
+        Table reloaded = restCatalog.loadTable(TableIdentifier.of("mydb", 
"t"));
+        
assertThat(reloaded.currentSnapshot().firstRowId()).isEqualTo(localSnapshot.firstRowId());
+        // manifest-level first_row_id is only exposed by the GA (1.10+) 
reader API
+        if (GA_ROW_LINEAGE_READER) {
+            List<Long> restDataManifestFirstRowIds = new ArrayList<>();
+            for (ManifestFile manifest : 
reloaded.currentSnapshot().dataManifests(reloaded.io())) {
+                assertThat(manifestFirstRowId(manifest)).isNotNull();
+                restDataManifestFirstRowIds.add(manifestFirstRowId(manifest));
+            }
+            assertThat(restDataManifestFirstRowIds)
+                    
.containsExactlyInAnyOrderElementsOf(localDataManifestFirstRowIds);
+
+            // the registered server table must carry the full row-id 
high-water mark:
+            // external REST writers allocate from it, so anything lower 
reuses ids
+            Long serverNextRowId =
+                    tableMetadataNextRowId(((BaseTable) 
reloaded).operations().current());
+            assertThat(serverNextRowId).isNotNull();
+            
assertThat(serverNextRowId).isGreaterThanOrEqualTo(localMetadata.nextRowId());
+
+            // one external append through the Iceberg API allocates above the 
watermark
+            reloaded.newAppend().commit();
+            Table afterExternal = 
restCatalog.loadTable(TableIdentifier.of("mydb", "t"));
+            assertThat(afterExternal.currentSnapshot().firstRowId())
+                    .isGreaterThanOrEqualTo(localMetadata.nextRowId());
+        }
+    }
+
+    /**
+     * Some REST catalogs (AWS Glue's Iceberg REST endpoint, for one) 
implement create, load, update
+     * and delete but not registerTable. Simulated by removing the endpoint 
from the set the client
+     * took from the server's config response.
+     */
+    @Test
+    public void testWithoutRegisterTableEndpoint() throws Exception {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.INT()}, new 
String[] {"k", "v"});
+        Map<String, String> customOptions = new HashMap<>();
+        customOptions.put(IcebergOptions.FORMAT_VERSION.key(), "3");
+        FileStoreTable table =
+                createPaimonTable(
+                        rowType,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        -1,
+                        "avro",
+                        customOptions);
+        // Iceberg metadata is only produced locally; the committer under test 
publishes it
+        FileStoreTable localTable =
+                table.copy(
+                        Collections.singletonMap(
+                                IcebergOptions.METADATA_ICEBERG_STORAGE.key(), 
"table-location"));
+        TableIdentifier identifier = TableIdentifier.of("mydb", "t");
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write = localTable.newWrite(commitUser);
+        TableCommitImpl commit = localTable.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(1, write.prepareCommit(true, 1));
+        write.write(GenericRow.of(3, 30));
+        commit.commit(2, write.prepareCommit(true, 2));
+        IcebergMetadata v1 = localMetadata(localTable, 1);
+        IcebergMetadata v2 = localMetadata(localTable, 2);
+
+        IcebergRestMetadataCommitter committer = new 
IcebergRestMetadataCommitter(table);
+        removeRegisterTableEndpoint(committer);
+
+        // zero-based v3 metadata publishes through the create/update path 
every catalog has
+        assertThat(v1.currentSnapshot().firstRowId()).isEqualTo(0L);
+        committer.commitMetadata(v1, null);
+        committer.commitMetadata(v2, v1);
+        Table icebergTable = restCatalog.loadTable(identifier);
+        assertThat(icebergTable.currentSnapshot().snapshotId()).isEqualTo(2);
+        assertThat(TableUtil.formatVersion(icebergTable)).isEqualTo(3);
+        assertThat(getIcebergResult())
+                .containsExactlyInAnyOrder("Record(1, 10)", "Record(2, 20)", 
"Record(3, 30)");
+
+        // a rebuild whose row-id space does not start at 0 can only be 
published by
+        // registration: fail closed and leave the catalog table untouched
+        write.write(GenericRow.of(4, 40));
+        commit.commit(3, write.prepareCommit(true, 3));
+        IcebergMetadata v3 = localMetadata(localTable, 3);
+        assertThat(v3.currentSnapshot().firstRowId()).isEqualTo(3L);
+        assertThatThrownBy(() -> committer.commitMetadata(v3, null))
+                .hasStackTraceContaining("does not advertise registerTable");
+        
assertThat(restCatalog.loadTable(identifier).currentSnapshot().snapshotId()).isEqualTo(2);
+        assertThat(registerFiles(localTable)).isEmpty();
+
+        // the same metadata is a plain update when the base is correct
+        committer.commitMetadata(v3, v2);
+        
assertThat(restCatalog.loadTable(identifier).currentSnapshot().snapshotId()).isEqualTo(3);
+
+        // recreating a lost table needs registration as well: nothing is 
created
+        restCatalog.dropTable(identifier, false);
+        write.write(GenericRow.of(5, 50));
+        commit.commit(4, write.prepareCommit(true, 4));
+        IcebergMetadata v4 = localMetadata(localTable, 4);
+        assertThatThrownBy(() -> committer.commitMetadata(v4, v3))
+                .hasStackTraceContaining("does not advertise registerTable");
+        assertThat(restCatalog.tableExists(identifier)).isFalse();
+
+        write.close();
+        commit.close();
+    }
+
+    @Test
+    public void testRecreateWithoutRegisterTableEndpointOnV2() throws 
Exception {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.INT()}, new 
String[] {"k", "v"});
+        FileStoreTable table =
+                createPaimonTable(
+                        rowType,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        -1,
+                        "avro",
+                        Collections.emptyMap());
+        FileStoreTable localTable =
+                table.copy(
+                        Collections.singletonMap(
+                                IcebergOptions.METADATA_ICEBERG_STORAGE.key(), 
"table-location"));
+        TableIdentifier identifier = TableIdentifier.of("mydb", "t");
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write = localTable.newWrite(commitUser);
+        TableCommitImpl commit = localTable.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(true, 1));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(2, write.prepareCommit(true, 2));
+        write.close();
+        commit.close();
+        IcebergMetadata v1 = localMetadata(localTable, 1);
+        IcebergMetadata v2 = localMetadata(localTable, 2);
+
+        IcebergRestMetadataCommitter committer = new 
IcebergRestMetadataCommitter(table);
+        removeRegisterTableEndpoint(committer);
+
+        committer.commitMetadata(v1, null);
+        
assertThat(restCatalog.loadTable(identifier).currentSnapshot().snapshotId()).isEqualTo(1);
+        // no base: the table is dropped and recreated, which needs no 
registration on v2
+        committer.commitMetadata(v2, null);
+        Table icebergTable = restCatalog.loadTable(identifier);
+        assertThat(icebergTable.currentSnapshot().snapshotId()).isEqualTo(2);
+        assertThat(TableUtil.formatVersion(icebergTable)).isEqualTo(2);
+        assertThat(getIcebergResult()).containsExactlyInAnyOrder("Record(1, 
10)", "Record(2, 20)");
+        assertThat(registerFiles(localTable)).isEmpty();
+    }
+
+    /**
+     * A catalog may keep referencing the registered metadata location, and 
rollback and rebuild
+     * paths reuse Paimon snapshot ids, so every registration writes a new 
file and never replaces
+     * an existing one.
+     */
+    @Test
+    public void testRegisteredMetadataFilesAreWriteOnce() throws Exception {
+        RowType rowType =
+                RowType.of(
+                        new DataType[] {DataTypes.INT(), DataTypes.INT()}, new 
String[] {"k", "v"});
+        Map<String, String> customOptions = new HashMap<>();
+        customOptions.put(IcebergOptions.FORMAT_VERSION.key(), "3");
+        FileStoreTable table =
+                createPaimonTable(
+                        rowType,
+                        Collections.emptyList(),
+                        Collections.emptyList(),
+                        -1,
+                        "avro",
+                        customOptions);
+        TableIdentifier identifier = TableIdentifier.of("mydb", "t");
+
+        String commitUser = UUID.randomUUID().toString();
+        TableWriteImpl<?> write = table.newWrite(commitUser);
+        TableCommitImpl commit = table.newCommit(commitUser);
+        write.write(GenericRow.of(1, 10));
+        commit.commit(1, write.prepareCommit(true, 1));
+        write.write(GenericRow.of(2, 20));
+        commit.commit(2, write.prepareCommit(true, 2));
+        IcebergMetadata v2 = localMetadata(table, 2);
+        assertThat(registerFiles(table)).isEmpty();
+
+        // the lost table is recreated by registration
+        restCatalog.dropTable(identifier, false);
+        write.write(GenericRow.of(3, 30));
+        commit.commit(3, write.prepareCommit(true, 3));
+        write.close();
+        commit.close();
+        IcebergMetadata v3 = localMetadata(table, 3);
+        
assertThat(restCatalog.loadTable(identifier).currentSnapshot().snapshotId()).isEqualTo(3);
+        List<Path> firstRegistration = registerFiles(table);
+        assertThat(firstRegistration).hasSize(1);
+        
assertThat(firstRegistration.get(0).getName()).startsWith("rest-register-v3-");
+        String firstContent = 
table.fileIO().readFileUtf8(firstRegistration.get(0));
+
+        // registering the same snapshot id again writes a second file, the 
first is untouched
+        restCatalog.dropTable(identifier, false);
+        new IcebergRestMetadataCommitter(table).commitMetadata(v3, v2);
+        
assertThat(restCatalog.loadTable(identifier).currentSnapshot().snapshotId()).isEqualTo(3);
+        List<Path> registrations = registerFiles(table);
+        
assertThat(registrations).hasSize(2).contains(firstRegistration.get(0));
+        
assertThat(table.fileIO().readFileUtf8(firstRegistration.get(0))).isEqualTo(firstContent);
+        for (Path path : registrations) {
+            assertThat(path.getName()).startsWith("rest-register-v3-");
+            assertThat(IcebergMetadata.fromPath(table.fileIO(), 
path).currentSnapshotId())
+                    .isEqualTo(3);
+        }
+    }
+
+    private static IcebergMetadata localMetadata(FileStoreTable table, long 
snapshotId) {
+        return IcebergMetadata.fromPath(
+                table.fileIO(),
+                new Path(
+                        catalogTableMetadataPath(table),
+                        String.format("v%d.metadata.json", snapshotId)));
+    }
+
+    private static List<Path> registerFiles(FileStoreTable table) throws 
Exception {
+        List<Path> files = new ArrayList<>();
+        for (org.apache.paimon.fs.FileStatus status :
+                table.fileIO().listStatus(catalogTableMetadataPath(table))) {
+            if (status.getPath().getName().startsWith("rest-register-")) {
+                files.add(status.getPath());
+            }
+        }
+        return files;
+    }
+
+    /** Makes the committer's REST client see a server that does not advertise 
registerTable. */
+    private static void 
removeRegisterTableEndpoint(IcebergRestMetadataCommitter committer)
+            throws Exception {
+        java.lang.reflect.Field catalogField =
+                
IcebergRestMetadataCommitter.class.getDeclaredField("restCatalog");
+        catalogField.setAccessible(true);
+        Object restCatalog = catalogField.get(committer);
+        java.lang.reflect.Field sessionField = 
RESTCatalog.class.getDeclaredField("sessionCatalog");
+        sessionField.setAccessible(true);
+        Object sessionCatalog = sessionField.get(restCatalog);
+        java.lang.reflect.Field endpointsField =
+                sessionCatalog.getClass().getDeclaredField("endpoints");
+        endpointsField.setAccessible(true);
+        Set<Endpoint> endpoints = new HashSet<>((Set<Endpoint>) 
endpointsField.get(sessionCatalog));
+        assertThat(endpoints.remove(Endpoint.V1_REGISTER_TABLE)).isTrue();
+        endpointsField.set(sessionCatalog, endpoints);
     }
 
     private static class TestRecord {
@@ -1565,4 +1844,35 @@ public class IcebergRestMetadataCommitterTest {
         String[] formats = new String[] {"orc", "parquet", "avro"};
         return formats[i];
     }
+
+    /** See IcebergRowLineageCompatibilityTest: GA reader API (1.10+) looked 
up reflectively. */
+    private static final boolean GA_ROW_LINEAGE_READER = 
detectGaRowLineageReader();
+
+    private static boolean detectGaRowLineageReader() {
+        try {
+            ManifestFile.class.getMethod("firstRowId");
+            return true;
+        } catch (NoSuchMethodException e) {
+            return false;
+        }
+    }
+
+    private static Long manifestFirstRowId(ManifestFile manifest) {
+        try {
+            return (Long) 
ManifestFile.class.getMethod("firstRowId").invoke(manifest);
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /** TableMetadata#nextRowId is a GA (1.10+) API; resolve reflectively. */
+    private static Long tableMetadataNextRowId(TableMetadata metadata) {
+        try {
+            return (Long) 
TableMetadata.class.getMethod("nextRowId").invoke(metadata);
+        } catch (NoSuchMethodException e) {
+            return null;
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException(e);
+        }
+    }
 }

Reply via email to