JingsongLi commented on code in PR #9348:
URL: https://github.com/apache/paimon/pull/9348#discussion_r3888247392


##########
paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java:
##########
@@ -459,6 +462,253 @@ private void createMetadata(
         }
     }
 
+    /**
+     * Create Iceberg metadata when no usable base metadata exists: either the 
very first Iceberg
+     * commit for this table, or a recovery after the previous metadata became 
unusable (format
+     * version change, missing row lineage, Iceberg-layer commit failure).
+     *
+     * <p>By default only the current snapshot is exposed to Iceberg. With 
{@link
+     * IcebergOptions#SYNC_FULL_HISTORY} the whole retained Paimon history is 
replayed instead, so
+     * Iceberg readers keep time travel and tags (see <a
+     * 
href="https://github.com/apache/paimon/issues/6107";>apache/paimon#6107</a>).
+     */
+    private void recreateMetadata(
+            long snapshotId,
+            @Nullable String inheritUuid,
+            int lastColumnIdFloor,
+            long nextRowIdFloor)
+            throws IOException {
+        if (syncFullHistory) {
+            rebuildFullHistory(snapshotId, inheritUuid, lastColumnIdFloor, 
nextRowIdFloor);
+        } else {
+            createMetadataWithoutBase(snapshotId, inheritUuid, 
lastColumnIdFloor, nextRowIdFloor);
+        }
+    }
+
+    /**
+     * Rebuild Iceberg metadata from every Paimon snapshot that is still 
retained, ending at {@code
+     * currentSnapshotId}: create metadata afresh for the earliest retained 
snapshot, then replay
+     * each following snapshot on top of its predecessor, exactly like live 
commits would have.
+     * Schemas, tags and (for format version 3) the row-id space therefore 
accumulate consistently
+     * across the whole replayed history.
+     *
+     * <p>Each replay step persists its metadata file, so an interrupted 
rebuild resumes from the
+     * newest already-written metadata on the next commit. Intermediate steps 
skip the version hint
+     * and the external catalog commit; only the final step publishes, so an 
external catalog sees a
+     * single transition. Replayed snapshots keep their original Paimon commit 
timestamps and are
+     * subject to the same retention policy ({@link 
CoreOptions#SNAPSHOT_NUM_RETAINED_MIN}, {@link
+     * CoreOptions#SNAPSHOT_TIME_RETAINED}, ...) that live commits apply.
+     */
+    private void rebuildFullHistory(
+            long currentSnapshotId,
+            @Nullable String inheritUuid,
+            int lastColumnIdFloor,
+            long nextRowIdFloor)
+            throws IOException {
+        SnapshotManager snapshotManager = table.snapshotManager();
+        Long earliest = snapshotManager.earliestSnapshotId();
+        long startId = earliest == null ? currentSnapshotId : 
Math.min(earliest, currentSnapshotId);
+
+        // Resume from the newest existing metadata below the current 
snapshot, if it is usable.
+        // Anything older than the newest existing file is stale by 
definition: live commits only
+        // ever read the immediately preceding metadata.
+        long baseId = -1;
+        for (long id = currentSnapshotId - 1; id >= startId; id--) {
+            Path metadataPath = pathFactory.toMetadataPath(id);
+            if (table.fileIO().exists(metadataPath)) {
+                try {
+                    IcebergMetadata metadata =
+                            IcebergMetadata.fromPath(table.fileIO(), 
metadataPath);
+                    if (isSameFormatVersion(metadata.formatVersion())
+                            && (formatVersion < 
IcebergMetadata.FORMAT_VERSION_V3
+                                    || metadata.nextRowId() != null)
+                            && coversRetainedPrefix(metadata, id, startId)) {

Review Comment:
   [P1] Reject unusable resume bases before selecting them
   
   This candidate is not checked against the live snapshot identity or schema 
definitions. If an abandoned-timeline/schema-incompatible base triggered the 
rebuild, this loop selects the same base again; the final 
`createMetadataWithBase` rejects it and recursively enters `rebuildFullHistory` 
through `recreateFromUnusableBase`. I reproduced this with rollback, an 
Iceberg-disabled commit that reused snapshot 2, and a full-history commit for 
snapshot 3: it ends in `StackOverflowError`. Please apply all 
extension-usability checks here, or explicitly exclude the rejected base and 
restart a non-recursive fresh replay.



##########
paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java:
##########
@@ -1274,6 +1635,28 @@ private void createMetadataWithBase(
         }
     }
 
+    /**
+     * Recreate metadata when the base metadata of a commit turned out to be 
unusable. At the head
+     * of the history this honors {@link IcebergOptions#SYNC_FULL_HISTORY}; in 
the middle of a
+     * {@link #rebuildFullHistory(long)} replay (where an unusable base should 
be impossible, since
+     * the replay itself validates or writes every base) it falls back to 
single-snapshot metadata
+     * instead of recursing into another replay.
+     */
+    private void recreateFromUnusableBase(
+            long snapshotId,
+            @Nullable String inheritUuid,
+            int lastColumnIdFloor,
+            long nextRowIdFloor,
+            boolean intermediate)
+            throws IOException {
+        if (intermediate) {
+            createMetadataWithoutBase(

Review Comment:
   [P1] Do not truncate history after an intermediate base-read failure
   
   When `intermediate` is true, this fallback writes metadata containing only 
`snapshotId`. The replay loop then extends that truncated base and can publish 
the final file without revalidating `coversRetainedPrefix`, so one transient 
I/O error, corruption, or concurrent replacement silently drops all earlier 
retained snapshots and tags. Please propagate/retry the failure or restart from 
`startId`, and validate the retained prefix again before final publication.



##########
paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java:
##########
@@ -937,8 +1255,45 @@ private void createMetadataWithBase(
             int lastColumnIdFloor,
             long nextRowIdFloor)
             throws IOException {
+        createMetadataWithBase(
+                fileChangesCollector,
+                indexFiles,
+                snapshot,
+                baseMetadataPath,
+                lastColumnIdFloor,
+                nextRowIdFloor,
+                false);
+    }
+
+    /**
+     * @param intermediate whether this metadata is an intermediate step of a 
{@link
+     *     #rebuildFullHistory} replay; intermediate steps skip the version 
hint and the external
+     *     catalog commit, which only the final step publishes.
+     */
+    private void createMetadataWithBase(
+            FileChangesCollector fileChangesCollector,
+            List<IndexManifestEntry> indexFiles,
+            Snapshot snapshot,
+            Path baseMetadataPath,
+            int lastColumnIdFloor,
+            long nextRowIdFloor,
+            boolean intermediate)
+            throws IOException {
         long snapshotId = snapshot.id();
-        IcebergMetadata baseMetadata = 
IcebergMetadata.fromPath(table.fileIO(), baseMetadataPath);
+        IcebergMetadata baseMetadata;
+        try {
+            baseMetadata = IcebergMetadata.fromPath(table.fileIO(), 
baseMetadataPath);
+        } catch (Exception e) {
+            // an unreadable base is an unusable base: recreate instead of 
failing the commit,
+            // so a corrupted metadata file self-heals like a structurally 
invalid one
+            LOG.warn(
+                    "Unreadable base Iceberg metadata {}, recreating 
metadata.",
+                    baseMetadataPath,
+                    e);
+            recreateFromUnusableBase(

Review Comment:
   [P1] Do not reset v3 row lineage after an unreadable base
   
   For a normal next commit, the caller floors are commonly zero. If the 
published v3 base is corrupt, this catch therefore rebuilds with a null UUID 
and `nextRowIdFloor == 0`, even though the unreadable metadata may already have 
issued higher row IDs. A regression test with `v2.next-row-id == 3` produces 
snapshot 3 with `first-row-id == 0`, reusing IDs 0-2; before this change the 
commit failed instead of publishing reset lineage. For v3, recover the 
UUID/high-water mark from trustworthy older or catalog metadata, and fail 
safely when it cannot be recovered.



##########
paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java:
##########
@@ -459,6 +462,253 @@ private void createMetadata(
         }
     }
 
+    /**
+     * Create Iceberg metadata when no usable base metadata exists: either the 
very first Iceberg
+     * commit for this table, or a recovery after the previous metadata became 
unusable (format
+     * version change, missing row lineage, Iceberg-layer commit failure).
+     *
+     * <p>By default only the current snapshot is exposed to Iceberg. With 
{@link
+     * IcebergOptions#SYNC_FULL_HISTORY} the whole retained Paimon history is 
replayed instead, so
+     * Iceberg readers keep time travel and tags (see <a
+     * 
href="https://github.com/apache/paimon/issues/6107";>apache/paimon#6107</a>).
+     */
+    private void recreateMetadata(
+            long snapshotId,
+            @Nullable String inheritUuid,
+            int lastColumnIdFloor,
+            long nextRowIdFloor)
+            throws IOException {
+        if (syncFullHistory) {
+            rebuildFullHistory(snapshotId, inheritUuid, lastColumnIdFloor, 
nextRowIdFloor);
+        } else {
+            createMetadataWithoutBase(snapshotId, inheritUuid, 
lastColumnIdFloor, nextRowIdFloor);
+        }
+    }
+
+    /**
+     * Rebuild Iceberg metadata from every Paimon snapshot that is still 
retained, ending at {@code
+     * currentSnapshotId}: create metadata afresh for the earliest retained 
snapshot, then replay
+     * each following snapshot on top of its predecessor, exactly like live 
commits would have.
+     * Schemas, tags and (for format version 3) the row-id space therefore 
accumulate consistently
+     * across the whole replayed history.
+     *
+     * <p>Each replay step persists its metadata file, so an interrupted 
rebuild resumes from the
+     * newest already-written metadata on the next commit. Intermediate steps 
skip the version hint
+     * and the external catalog commit; only the final step publishes, so an 
external catalog sees a
+     * single transition. Replayed snapshots keep their original Paimon commit 
timestamps and are
+     * subject to the same retention policy ({@link 
CoreOptions#SNAPSHOT_NUM_RETAINED_MIN}, {@link
+     * CoreOptions#SNAPSHOT_TIME_RETAINED}, ...) that live commits apply.
+     */
+    private void rebuildFullHistory(
+            long currentSnapshotId,
+            @Nullable String inheritUuid,
+            int lastColumnIdFloor,
+            long nextRowIdFloor)
+            throws IOException {
+        SnapshotManager snapshotManager = table.snapshotManager();
+        Long earliest = snapshotManager.earliestSnapshotId();
+        long startId = earliest == null ? currentSnapshotId : 
Math.min(earliest, currentSnapshotId);
+
+        // Resume from the newest existing metadata below the current 
snapshot, if it is usable.
+        // Anything older than the newest existing file is stale by 
definition: live commits only
+        // ever read the immediately preceding metadata.
+        long baseId = -1;
+        for (long id = currentSnapshotId - 1; id >= startId; id--) {
+            Path metadataPath = pathFactory.toMetadataPath(id);
+            if (table.fileIO().exists(metadataPath)) {
+                try {
+                    IcebergMetadata metadata =
+                            IcebergMetadata.fromPath(table.fileIO(), 
metadataPath);
+                    if (isSameFormatVersion(metadata.formatVersion())
+                            && (formatVersion < 
IcebergMetadata.FORMAT_VERSION_V3
+                                    || metadata.nextRowId() != null)
+                            && coversRetainedPrefix(metadata, id, startId)) {
+                        baseId = id;
+                    }
+                } catch (Exception e) {
+                    LOG.warn(
+                            "Failed to read existing Iceberg metadata {}, 
rebuilding history from scratch",
+                            metadataPath,
+                            e);
+                }
+                break;
+            }
+        }
+
+        long firstWithBase;
+        boolean freshRebuild = baseId == -1;
+        StaleBuild staleBuild = null;
+        if (freshRebuild) {
+            // No usable base: the whole old build is stale. Nothing of it is 
deleted yet, so
+            // an external catalog that still points at the old metadata keeps 
a fully readable
+            // table during the entire replay; the old files are cleaned only 
after the final
+            // step has published. Their references are collected up front, 
tolerating
+            // unreadable files (that is what triggered some rebuilds in the 
first place).
+            staleBuild = collectStaleBuild(currentSnapshotId);
+            // a leftover file in the replay range must not survive as a 
replay step's output:
+            // it may match the step's commit identity (a regenerated build of 
the same Paimon
+            // snapshot does) while carrying another format or content, so 
each target is
+            // removed just before its replacement is written
+            table.fileIO().deleteQuietly(pathFactory.toMetadataPath(startId));
+            createMetadataWithoutBase(
+                    startId,
+                    inheritUuid,
+                    lastColumnIdFloor,
+                    nextRowIdFloor,
+                    startId != currentSnapshotId);
+            firstWithBase = startId + 1;
+        } else {
+            firstWithBase = baseId + 1;
+        }
+
+        for (long id = firstWithBase; id <= currentSnapshotId; id++) {
+            long snapshotId = id;
+            Snapshot snapshot = snapshotManager.snapshot(snapshotId);
+            if (freshRebuild && snapshotId != currentSnapshotId) {
+                // see above; the final step replaces its twin through the 
regular write path
+                
table.fileIO().deleteQuietly(pathFactory.toMetadataPath(snapshotId));

Review Comment:
   [P1] Keep the published metadata file intact until replay commits
   
   At this point `v${snapshotId}.metadata.json` can still be the file 
referenced by Hive's `metadata_location` or Hadoop's version hint, especially 
when `snapshotId == currentSnapshotId - 1`. Deleting it before 
`createMetadataWithBase` finishes creates a reader outage, and any later 
manifest/DV/metadata write failure leaves the external pointer aimed at a 
missing file until another callback succeeds. `intermediate` suppresses 
hint/catalog updates, but it does not make mutations to the already-published 
path invisible. Please build the replay under unique staging paths and switch 
the published head only after the final metadata is durable.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to