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


##########
paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java:
##########
@@ -459,6 +462,397 @@ 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>Every replay step writes to a uniquely named staging path; the 
published chain, the
+     * version hint and the external catalog stay untouched until the final 
staged metadata is
+     * durable, and only then is the staged chain promoted into place. A 
rebuild that fails at any
+     * point therefore leaves the previously published metadata fully 
readable, and its staged
+     * leftovers are removed by the next rebuild. 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);
+
+        deleteStagedLeftovers();
+
+        // 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 (isUsableReplayBase(metadata, id, startId)) {
+                        baseId = id;
+                    }
+                } catch (Exception e) {
+                    LOG.warn(
+                            "Failed to read existing Iceberg metadata {}, 
rebuilding history from scratch",
+                            metadataPath,
+                            e);
+                }
+                break;
+            }
+        }
+
+        String rebuildUuid = UUID.randomUUID().toString();
+        long firstStagedId;
+        boolean freshRebuild = baseId == -1;
+        StaleBuild staleBuild = null;
+        if (freshRebuild) {
+            // No usable base: the whole old build is stale. Nothing of it is 
touched during the
+            // replay, so an external catalog that still points at the old 
metadata keeps a fully
+            // readable table; the old files are replaced and cleaned only by 
the promotion. Their
+            // references are collected up front, tolerating unreadable files 
(that is what
+            // triggered some rebuilds in the first place).
+            staleBuild = collectStaleBuild(currentSnapshotId);
+            firstStagedId = startId;
+            createMetadataWithoutBase(
+                    startId,
+                    inheritUuid,
+                    lastColumnIdFloor,
+                    nextRowIdFloor,
+                    stagedMetadataPath(rebuildUuid, startId));
+        } else {
+            firstStagedId = baseId + 1;
+        }
+
+        for (long id = firstStagedId == startId ? startId + 1 : firstStagedId;
+                id <= currentSnapshotId;
+                id++) {
+            long snapshotId = id;
+            Snapshot snapshot = snapshotManager.snapshot(snapshotId);
+            Path basePath =
+                    snapshotId - 1 < firstStagedId
+                            ? pathFactory.toMetadataPath(snapshotId - 1)
+                            : stagedMetadataPath(rebuildUuid, snapshotId - 1);
+            createMetadataWithBase(
+                    (removedFiles, addedFiles) ->
+                            collectFileChanges(snapshotId, removedFiles, 
addedFiles),
+                    indexFileHandler.scan(snapshot, DELETION_VECTORS_INDEX),
+                    snapshot,
+                    basePath,
+                    lastColumnIdFloor,
+                    nextRowIdFloor,
+                    stagedMetadataPath(rebuildUuid, snapshotId));
+        }
+
+        boolean promoted =
+                promoteStagedReplay(rebuildUuid, firstStagedId, 
currentSnapshotId, startId);
+        if (promoted && staleBuild != null) {
+            deleteStaleBuild(staleBuild, currentSnapshotId, startId);
+        }
+    }
+
+    /**
+     * Whether an existing metadata file can serve as the resume base of a 
full-history replay. On
+     * top of the structural checks, the base must describe the live timeline: 
an abandoned base (a
+     * rolled-back Paimon snapshot reused the id) or one carrying re-evolved 
schema definitions
+     * would be rejected again by the first replay step, and a rebuild must 
never select a base its
+     * own replay refuses to extend.
+     */
+    private boolean isUsableReplayBase(IcebergMetadata metadata, long baseId, 
long startId) {
+        if (!isSameFormatVersion(metadata.formatVersion())) {
+            return false;
+        }
+        if (formatVersion >= IcebergMetadata.FORMAT_VERSION_V3 && 
metadata.nextRowId() == null) {
+            return false;
+        }
+        if (!coversRetainedPrefix(metadata, baseId, startId)) {
+            return false;
+        }
+        SnapshotManager snapshotManager = table.snapshotManager();
+        if (!snapshotManager.snapshotExists(baseId)
+                || !metadataMatchesSnapshot(metadata, 
snapshotManager.snapshot(baseId))) {
+            return false;
+        }
+        SchemaCache schemaCache = new SchemaCache();
+        long latestSchemaId = schemaCache.getLatestSchemaId();
+        for (IcebergSchema known : metadata.schemas()) {
+            if (known.schemaId() > latestSchemaId
+                    || !known.equals(schemaCache.get(known.schemaId()))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private Path stagedMetadataPath(String rebuildUuid, long snapshotId) {
+        return new Path(
+                pathFactory.metadataDirectory(),
+                String.format("rebuild-%s-v%d.metadata.json", rebuildUuid, 
snapshotId));
+    }
+
+    /** Staged files of crashed or superseded rebuilds; only ever garbage. */
+    private void deleteStagedLeftovers() throws IOException {
+        FileStatus[] statuses;
+        try {
+            statuses = 
table.fileIO().listStatus(pathFactory.metadataDirectory());
+        } catch (FileNotFoundException e) {
+            return;
+        }
+        for (FileStatus status : statuses) {
+            String name = status.getPath().getName();
+            if (name.startsWith("rebuild-") && 
name.endsWith(".metadata.json")) {
+                table.fileIO().deleteQuietly(status.getPath());
+            }
+        }
+    }
+
+    /**
+     * Switch the published chain to the staged one, then the version hint and 
the external catalog.
+     * The staged head is durable and validated before any published path is 
touched; the remaining
+     * window is the per-file replacement of each chain position, ending one 
below the head, and
+     * every path serves complete metadata again as soon as its replacement 
lands.
+     *
+     * @return false if a newer commit superseded this rebuild; the staged 
files are discarded and
+     *     the published chain is left for that commit's own rebuild
+     */
+    private boolean promoteStagedReplay(
+            String rebuildUuid, long firstStagedId, long currentSnapshotId, 
long startId)
+            throws IOException {
+        IcebergMetadata head =
+                IcebergMetadata.fromPath(
+                        table.fileIO(), stagedMetadataPath(rebuildUuid, 
currentSnapshotId));
+        Preconditions.checkState(
+                head.currentSnapshotId() == currentSnapshotId,
+                "Staged replay head is at snapshot %s instead of %s",
+                head.currentSnapshotId(),
+                currentSnapshotId);
+        // a truncated chain must never be promoted, no matter what produced it
+        Preconditions.checkState(
+                coversRetainedPrefix(head, currentSnapshotId, startId),
+                "Staged replay head for snapshot %s does not cover the 
retained history",
+                currentSnapshotId);
+
+        Long latest = table.snapshotManager().latestSnapshotId();
+        if (latest == null || latest != currentSnapshotId) {
+            for (long id = firstStagedId; id <= currentSnapshotId; id++) {
+                table.fileIO().deleteQuietly(stagedMetadataPath(rebuildUuid, 
id));
+            }
+            return false;
+        }
+
+        for (long id = firstStagedId; id <= currentSnapshotId; id++) {
+            Path staged = stagedMetadataPath(rebuildUuid, id);
+            Path published = pathFactory.toMetadataPath(id);
+            if (table.fileIO().exists(published)) {
+                table.fileIO().deleteQuietly(published);

Review Comment:
   [P1] Keep the published head crash-safe during promotion. This still deletes 
an existing `vN.metadata.json` before the staged rename. If the process dies in 
that window, or both `rename` attempts return false/throw (which `FileIO` 
explicitly permits), Hive/REST and the version hint can remain pointed at a 
missing head indefinitely—the same availability failure staging was meant to 
eliminate. Please use an atomic overwrite/replace primitive for published 
positions, or publish a generation-specific immutable head and switch only the 
external pointer; add a failure-injection test at the head promotion.



##########
paimon-core/src/main/java/org/apache/paimon/iceberg/IcebergCommitCallback.java:
##########
@@ -459,6 +462,397 @@ 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>Every replay step writes to a uniquely named staging path; the 
published chain, the
+     * version hint and the external catalog stay untouched until the final 
staged metadata is
+     * durable, and only then is the staged chain promoted into place. A 
rebuild that fails at any
+     * point therefore leaves the previously published metadata fully 
readable, and its staged
+     * leftovers are removed by the next rebuild. 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);
+
+        deleteStagedLeftovers();
+
+        // 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 (isUsableReplayBase(metadata, id, startId)) {
+                        baseId = id;
+                    }
+                } catch (Exception e) {
+                    LOG.warn(
+                            "Failed to read existing Iceberg metadata {}, 
rebuilding history from scratch",
+                            metadataPath,
+                            e);
+                }
+                break;
+            }
+        }
+
+        String rebuildUuid = UUID.randomUUID().toString();
+        long firstStagedId;
+        boolean freshRebuild = baseId == -1;
+        StaleBuild staleBuild = null;
+        if (freshRebuild) {
+            // No usable base: the whole old build is stale. Nothing of it is 
touched during the
+            // replay, so an external catalog that still points at the old 
metadata keeps a fully
+            // readable table; the old files are replaced and cleaned only by 
the promotion. Their
+            // references are collected up front, tolerating unreadable files 
(that is what
+            // triggered some rebuilds in the first place).
+            staleBuild = collectStaleBuild(currentSnapshotId);
+            firstStagedId = startId;
+            createMetadataWithoutBase(
+                    startId,
+                    inheritUuid,
+                    lastColumnIdFloor,
+                    nextRowIdFloor,
+                    stagedMetadataPath(rebuildUuid, startId));
+        } else {
+            firstStagedId = baseId + 1;
+        }
+
+        for (long id = firstStagedId == startId ? startId + 1 : firstStagedId;
+                id <= currentSnapshotId;
+                id++) {
+            long snapshotId = id;
+            Snapshot snapshot = snapshotManager.snapshot(snapshotId);
+            Path basePath =
+                    snapshotId - 1 < firstStagedId
+                            ? pathFactory.toMetadataPath(snapshotId - 1)
+                            : stagedMetadataPath(rebuildUuid, snapshotId - 1);
+            createMetadataWithBase(
+                    (removedFiles, addedFiles) ->
+                            collectFileChanges(snapshotId, removedFiles, 
addedFiles),
+                    indexFileHandler.scan(snapshot, DELETION_VECTORS_INDEX),
+                    snapshot,
+                    basePath,
+                    lastColumnIdFloor,
+                    nextRowIdFloor,
+                    stagedMetadataPath(rebuildUuid, snapshotId));
+        }
+
+        boolean promoted =
+                promoteStagedReplay(rebuildUuid, firstStagedId, 
currentSnapshotId, startId);
+        if (promoted && staleBuild != null) {
+            deleteStaleBuild(staleBuild, currentSnapshotId, startId);
+        }
+    }
+
+    /**
+     * Whether an existing metadata file can serve as the resume base of a 
full-history replay. On
+     * top of the structural checks, the base must describe the live timeline: 
an abandoned base (a
+     * rolled-back Paimon snapshot reused the id) or one carrying re-evolved 
schema definitions
+     * would be rejected again by the first replay step, and a rebuild must 
never select a base its
+     * own replay refuses to extend.
+     */
+    private boolean isUsableReplayBase(IcebergMetadata metadata, long baseId, 
long startId) {
+        if (!isSameFormatVersion(metadata.formatVersion())) {
+            return false;
+        }
+        if (formatVersion >= IcebergMetadata.FORMAT_VERSION_V3 && 
metadata.nextRowId() == null) {
+            return false;
+        }
+        if (!coversRetainedPrefix(metadata, baseId, startId)) {
+            return false;
+        }
+        SnapshotManager snapshotManager = table.snapshotManager();
+        if (!snapshotManager.snapshotExists(baseId)
+                || !metadataMatchesSnapshot(metadata, 
snapshotManager.snapshot(baseId))) {
+            return false;
+        }
+        SchemaCache schemaCache = new SchemaCache();
+        long latestSchemaId = schemaCache.getLatestSchemaId();
+        for (IcebergSchema known : metadata.schemas()) {
+            if (known.schemaId() > latestSchemaId
+                    || !known.equals(schemaCache.get(known.schemaId()))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private Path stagedMetadataPath(String rebuildUuid, long snapshotId) {
+        return new Path(
+                pathFactory.metadataDirectory(),
+                String.format("rebuild-%s-v%d.metadata.json", rebuildUuid, 
snapshotId));
+    }
+
+    /** Staged files of crashed or superseded rebuilds; only ever garbage. */
+    private void deleteStagedLeftovers() throws IOException {
+        FileStatus[] statuses;
+        try {
+            statuses = 
table.fileIO().listStatus(pathFactory.metadataDirectory());
+        } catch (FileNotFoundException e) {
+            return;
+        }
+        for (FileStatus status : statuses) {
+            String name = status.getPath().getName();
+            if (name.startsWith("rebuild-") && 
name.endsWith(".metadata.json")) {
+                table.fileIO().deleteQuietly(status.getPath());
+            }
+        }
+    }
+
+    /**
+     * Switch the published chain to the staged one, then the version hint and 
the external catalog.
+     * The staged head is durable and validated before any published path is 
touched; the remaining
+     * window is the per-file replacement of each chain position, ending one 
below the head, and
+     * every path serves complete metadata again as soon as its replacement 
lands.
+     *
+     * @return false if a newer commit superseded this rebuild; the staged 
files are discarded and
+     *     the published chain is left for that commit's own rebuild
+     */
+    private boolean promoteStagedReplay(
+            String rebuildUuid, long firstStagedId, long currentSnapshotId, 
long startId)
+            throws IOException {
+        IcebergMetadata head =
+                IcebergMetadata.fromPath(
+                        table.fileIO(), stagedMetadataPath(rebuildUuid, 
currentSnapshotId));
+        Preconditions.checkState(
+                head.currentSnapshotId() == currentSnapshotId,
+                "Staged replay head is at snapshot %s instead of %s",
+                head.currentSnapshotId(),
+                currentSnapshotId);
+        // a truncated chain must never be promoted, no matter what produced it
+        Preconditions.checkState(
+                coversRetainedPrefix(head, currentSnapshotId, startId),
+                "Staged replay head for snapshot %s does not cover the 
retained history",
+                currentSnapshotId);
+
+        Long latest = table.snapshotManager().latestSnapshotId();

Review Comment:
   [P1] Prevent an older replay from publishing after a newer commit. This 
latest-snapshot check is separated from both the per-file promotion and the 
hint/catalog update. Snapshot N+1 can commit after this check, extend/publish 
metadata, and then this slow N replay overwrites the hint and external catalog 
back to N; depending on timing, N+1 can also extend the old partial `vN` chain 
and permanently lose the rebuilt history. Recheck is still TOCTOU unless 
publication is serialized/claimed conditionally. Please make replay publication 
mutually exclusive with later callbacks (or use a CAS against the exact 
catalog/base head), and test an N+1 callback interleaved after this check.



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