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 79a8b64473 [iceberg] Fix Iceberg metadata after a rollback (#9036)
79a8b64473 is described below
commit 79a8b64473f63d28f6fe9ea2c0c8b7b2c1e0900c
Author: Jiajia Li <[email protected]>
AuthorDate: Mon Aug 10 16:40:07 2026 +0800
[iceberg] Fix Iceberg metadata after a rollback (#9036)
---
.../paimon/iceberg/IcebergCommitCallback.java | 501 ++++++++++--
.../paimon/table/AbstractFileStoreTable.java | 3 +
.../paimon/iceberg/IcebergCompatibilityTest.java | 851 +++++++++++++++++++++
.../iceberg/RecordingIcebergMetadataCommitter.java | 78 ++
.../services/org.apache.paimon.factories.Factory | 1 +
.../iceberg/IcebergRestMetadataCommitter.java | 55 +-
.../iceberg/IcebergRestMetadataCommitterTest.java | 231 ++++++
7 files changed, 1648 insertions(+), 72 deletions(-)
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 336b0fe865..4b6776d339 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
@@ -25,6 +25,7 @@ import org.apache.paimon.data.GenericArray;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.factories.FactoryException;
import org.apache.paimon.factories.FactoryUtil;
+import org.apache.paimon.fs.FileStatus;
import org.apache.paimon.fs.Path;
import org.apache.paimon.iceberg.manifest.IcebergConversions;
import org.apache.paimon.iceberg.manifest.IcebergDataFileMeta;
@@ -79,6 +80,7 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayList;
@@ -296,6 +298,23 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
List<IndexManifestEntry> indexFiles) {
long snapshotId = snapshot.id();
try {
+ // a stale callback outliving a rollback (or drop and recreate)
must do nothing;
+ // read the snapshot file directly - snapshot caches may predate
the rollback
+ Snapshot current;
+ try {
+ current =
+ SnapshotManager.tryFromPath(
+ table.fileIO(),
table.snapshotManager().snapshotPath(snapshotId));
+ } catch (FileNotFoundException e) {
+ return;
+ }
+ if (!commitIdentity(current).equals(commitIdentity(snapshot))) {
+ return;
+ }
+ // the snapshot cache may still hold a rolled-back timeline under
reused ids;
+ // every by-id read below must see the disk state the guard just
verified
+ table.snapshotManager().invalidateCache();
+
if (snapshotId == Snapshot.FIRST_SNAPSHOT_ID) {
// If Iceberg metadata is stored separately in another
directory, dropping the table
// will not delete old Iceberg metadata. So we delete them
here, when the table is
@@ -303,8 +322,84 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
table.fileIO().delete(pathFactory.metadataDirectory(), true);
}
+ String abandonedUuid = null;
+ int abandonedLastColumnId = 0;
if (table.fileIO().exists(pathFactory.toMetadataPath(snapshotId)))
{
- return;
+ if (metadataMatchesSnapshot(snapshotId, snapshot)) {
+ // a retry repairs hint, pointer and files only while this
snapshot is
+ // still the head; a replay of an older committable must
not move them back
+ Long latestForRepair =
table.snapshotManager().latestSnapshotId();
+ if (latestForRepair == null || latestForRepair !=
snapshotId) {
+ return;
+ }
+ if (readVersionHint() != snapshotId) {
+ table.fileIO()
+ .overwriteFileUtf8(
+ new Path(
+
pathFactory.metadataDirectory(),
+ VERSION_HINT_FILENAME),
+ String.valueOf(snapshotId));
+ }
+ if (metadataCommitter != null) {
+ // recommit the pointer (previous version as base, so
a lagging
+ // catalog advances) before retiring files it may
still reference
+ Path existingPath =
pathFactory.toMetadataPath(snapshotId);
+ Path basePath = pathFactory.toMetadataPath(snapshotId
- 1);
+ IcebergMetadata base =
+ table.fileIO().exists(basePath)
+ ?
IcebergMetadata.fromPath(table.fileIO(), basePath)
+ : null;
+ commitToExternalCatalog(
+ IcebergMetadata.fromPath(table.fileIO(),
existingPath),
+ existingPath,
+ base,
+ base == null ? null : basePath);
+ }
+ // a failed earlier attempt skipped the normal
post-publication cleanup
+ deleteApplicableMetadataFiles(snapshotId);
+ retireAbandonedSuffix();
+ table.fileIO()
+ .deleteQuietly(
+ new Path(
+ pathFactory.metadataDirectory(),
+ RETIRE_PENDING_FILENAME));
+ return;
+ }
+ // a reused snapshot id: only the current head may replace the
abandoned
+ // metadata; a delayed replay must not move hint or pointer
backwards
+ Long latestNow = table.snapshotManager().latestSnapshotId();
+ if (latestNow == null || latestNow != snapshotId) {
+ return;
+ }
+ // read the identity now, delete only at the write site:
readers and the
+ // catalog pointer keep a working file until the replacement
is built
+ IcebergMetadata abandoned =
tryReadMetadata(pathFactory.toMetadataPath(snapshotId));
+ if (abandoned != null) {
+ abandonedUuid = abandoned.tableUuid();
+ abandonedLastColumnId = abandoned.lastColumnId();
+ }
+ }
+ // steady-state commits skip the listing; anything suspicious
lists the actual
+ // files, because the hint alone can lag while readers still probe
past it
+ Path retirePending = new Path(pathFactory.metadataDirectory(),
RETIRE_PENDING_FILENAME);
+ boolean suspectRollback =
+ abandonedUuid != null
+ || readVersionHint() != snapshotId - 1
+ ||
table.fileIO().exists(pathFactory.toMetadataPath(snapshotId + 1))
+ || table.fileIO().exists(retirePending);
+ long newestExisting = suspectRollback ?
newestExistingMetadataVersion() : -1;
+ boolean retireSuffix = abandonedUuid != null || newestExisting >
snapshotId;
+ if (retireSuffix && newestExisting > snapshotId) {
+ // the newest abandoned version carries the authoritative
high-water mark
+ IcebergMetadata surviving =
+
tryReadMetadata(pathFactory.toMetadataPath(newestExisting));
+ if (surviving != null) {
+ if (abandonedUuid == null) {
+ abandonedUuid = surviving.tableUuid();
+ }
+ abandonedLastColumnId =
+ Math.max(abandonedLastColumnId,
surviving.lastColumnId());
+ }
}
Path baseMetadataPath = pathFactory.toMetadataPath(snapshotId - 1);
@@ -320,9 +415,19 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
.equals(DELETION_VECTORS_INDEX))
.collect(Collectors.toList()),
snapshot,
- baseMetadataPath);
+ baseMetadataPath,
+ abandonedLastColumnId);
} else {
- createMetadataWithoutBase(snapshotId);
+ createMetadataWithoutBase(snapshotId, abandonedUuid,
abandonedLastColumnId);
+ }
+
+ if (retireSuffix) {
+ // only after the replacement is durable, so readers keep a
working head
+ retireAbandonedSuffix();
+ }
+ if (suspectRollback) {
+ // the listing ran and every leftover above the head is gone
+ table.fileIO().deleteQuietly(retirePending);
}
} catch (IOException e) {
throw new UncheckedIOException(e);
@@ -334,6 +439,17 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
//
-------------------------------------------------------------------------------------
private void createMetadataWithoutBase(long snapshotId) throws IOException
{
+ createMetadataWithoutBase(snapshotId, null, 0);
+ }
+
+ private void createMetadataWithoutBase(long snapshotId, @Nullable String
inheritUuid)
+ throws IOException {
+ createMetadataWithoutBase(snapshotId, inheritUuid, 0);
+ }
+
+ private void createMetadataWithoutBase(
+ long snapshotId, @Nullable String inheritUuid, int
lastColumnIdFloor)
+ throws IOException {
SnapshotReader snapshotReader =
table.newSnapshotReader().withSnapshot(snapshotId);
Snapshot paimonSnapshot = table.snapshotManager().snapshot(snapshotId);
SchemaCache schemaCache = new SchemaCache();
@@ -396,7 +512,9 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
String manifestListFileName =
manifestList.writeWithoutRolling(allManifestFileMetas);
+ // current schema follows the latest; the snapshot entry records its
own schema
int schemaId = (int) schemaCache.getLatestSchemaId();
+ int snapshotSchemaId = (int) paimonSnapshot.schemaId();
IcebergSchema icebergSchema = schemaCache.get(schemaId);
List<IcebergPartitionField> partitionFields =
getPartitionFields(table.schema().partitionKeys(),
icebergSchema);
@@ -410,10 +528,11 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
snapshotId,
snapshotId,
snapshotId == Snapshot.FIRST_SNAPSHOT_ID ? null :
(Long) (snapshotId - 1),
- System.currentTimeMillis(),
+ // the Paimon snapshot's own commit time, the as-of
time readers see
+ paimonSnapshot.timeMillis(),
snapshotSummary,
pathFactory.toManifestListPath(manifestListFileName).toString(),
- schemaId,
+ snapshotSchemaId,
null,
null);
@@ -425,7 +544,9 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
// After https://github.com/apache/paimon/issues/6107 we can add tags
here.
Map<String, IcebergRef> refs = new HashMap<>();
- String tableUuid = UUID.randomUUID().toString();
+ // keep the identity of the metadata this rebuild replaces, so already
loaded readers
+ // and external catalogs keep refreshing the same table
+ String tableUuid = inheritUuid != null ? inheritUuid :
UUID.randomUUID().toString();
List<IcebergSchema> allSchemas =
IntStream.rangeClosed(0, schemaId)
@@ -437,7 +558,14 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
tableUuid,
table.location().toString(),
snapshotId,
- icebergSchema.highestFieldId(),
+ // every emitted schema counts, and a rebuild must not
regress
+ // below the replaced metadata's high-water mark
+ Math.max(
+ lastColumnIdFloor,
+ allSchemas.stream()
+
.mapToInt(IcebergSchema::highestFieldId)
+ .max()
+
.orElse(icebergSchema.highestFieldId())),
allSchemas,
schemaId,
Collections.singletonList(new
IcebergPartitionSpec(partitionFields)),
@@ -452,26 +580,31 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
refs);
Path metadataPath = pathFactory.toMetadataPath(snapshotId);
- table.fileIO().tryToWriteAtomic(metadataPath, metadata.toJson());
- table.fileIO()
- .overwriteFileUtf8(
- new Path(pathFactory.metadataDirectory(),
VERSION_HINT_FILENAME),
- String.valueOf(snapshotId));
-
- expireAllBefore(snapshotId);
-
- if (metadataCommitter != null) {
- switch (metadataCommitter.identifier()) {
- case "hive":
- metadataCommitter.commitMetadata(metadataPath, null);
- break;
- case "rest":
- metadataCommitter.commitMetadata(metadata, null);
- break;
- default:
- throw new UnsupportedOperationException(
- "Unsupported metadata committer: " +
metadataCommitter.identifier());
- }
+ // atomic-first: where rename overwrites, a stale twin is replaced
with no window at
+ // all; otherwise fall back to delete-then-write, the smallest window
available
+ boolean written = table.fileIO().tryToWriteAtomic(metadataPath,
metadata.toJson());
+ if (!written
+ && table.fileIO().exists(metadataPath)
+ && !metadataMatchesSnapshot(snapshotId, paimonSnapshot)) {
+ table.fileIO().deleteQuietly(metadataPath);
+ written = table.fileIO().tryToWriteAtomic(metadataPath,
metadata.toJson());
+ }
+ if (!written && !metadataMatchesSnapshot(snapshotId, paimonSnapshot)) {
+ // no twin published this snapshot's metadata; fail so the commit
retries
+ throw new IllegalStateException("Failed to replace Iceberg
metadata " + metadataPath);
+ }
+ // a delayed callback may still write its metadata (a newer commit
extends it), but
+ // only the current head may move the hint and the external catalog
+ Long latestAtPublish = table.snapshotManager().latestSnapshotId();
+ if (latestAtPublish != null && latestAtPublish == snapshotId) {
+ table.fileIO()
+ .overwriteFileUtf8(
+ new Path(pathFactory.metadataDirectory(),
VERSION_HINT_FILENAME),
+ String.valueOf(snapshotId));
+ commitToExternalCatalog(metadata, metadataPath, null, null);
+ // cleanup only after the catalog serves the new head: a skipped
or failed
+ // publication must not delete files an external pointer still
references
+ expireAllBefore(snapshotId);
}
}
@@ -608,21 +741,232 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
// Create metadata based on old ones
//
-------------------------------------------------------------------------------------
+ /**
+ * Whether the existing metadata for {@code snapshotId} was built from
this very Paimon
+ * snapshot, judged by the commit identity in the snapshot summary.
Unreadable counts as a
+ * mismatch; metadata without an identity (older releases) is trusted, so
the protection only
+ * covers metadata written since.
+ *
+ * <p>A replacement reuses its metadata version (versions are keyed by
Paimon snapshot id), so
+ * readers that already loaded the abandoned version converge only after
reloading the table.
+ */
+ /**
+ * Deletes every metadata version above the current Paimon snapshots,
which would otherwise
+ * shadow the replaced timeline for readers probing past the hint. A
failed deletion fails the
+ * commit so a retry finishes the job; referenced manifests are left to
orphan cleanup (the
+ * shared prefix makes reference counting non-trivial).
+ */
+ private void retireAbandonedSuffix() throws IOException {
+ for (FileStatus status :
table.fileIO().listStatus(pathFactory.metadataDirectory())) {
+ String name = status.getPath().getName();
+ if (!name.startsWith("v") || !name.endsWith(".metadata.json")) {
+ continue;
+ }
+ long version;
+ try {
+ version = Long.parseLong(name.substring(1, name.indexOf('.')));
+ } catch (NumberFormatException ignored) {
+ continue;
+ }
+ Long latestNow = table.snapshotManager().latestSnapshotId();
+ if (latestNow == null || version <= latestNow) {
+ continue;
+ }
+ table.fileIO().deleteQuietly(status.getPath());
+ if (table.fileIO().exists(status.getPath())) {
+ throw new IllegalStateException(
+ "Failed to retire abandoned Iceberg metadata " +
status.getPath());
+ }
+ }
+ }
+
+ static final String RETIRE_PENDING_FILENAME = "retire-pending";
+
+ /**
+ * Marks that a rollback may have left abandoned metadata behind; written
before the rollback
+ * deletes anything, and cleared once a commit has listed and retired the
leftovers.
+ */
+ public static void markRetirePendingForRollback(FileStoreTable table) {
+ if
(table.coreOptions().toConfiguration().get(IcebergOptions.METADATA_ICEBERG_STORAGE)
+ == IcebergOptions.StorageType.DISABLED) {
+ return;
+ }
+ try {
+ Path dir = catalogTableMetadataPath(table);
+ if (table.fileIO().exists(dir)) {
+ table.fileIO().overwriteFileUtf8(new Path(dir,
RETIRE_PENDING_FILENAME), "");
+ }
+ } catch (Exception e) {
+ // best-effort: the commit-time suspicion gate still covers the
common cases
+ }
+ }
+
+ /** The version recorded in the hint file, or -1 when absent or
unreadable. */
+ private long readVersionHint() {
+ try {
+ return Long.parseLong(
+ table.fileIO()
+ .readFileUtf8(
+ new Path(
+ pathFactory.metadataDirectory(),
VERSION_HINT_FILENAME))
+ .trim());
+ } catch (Exception e) {
+ return -1;
+ }
+ }
+
+ private void commitToExternalCatalog(
+ IcebergMetadata metadata,
+ Path metadataPath,
+ @Nullable IcebergMetadata baseMetadata,
+ @Nullable Path baseMetadataPath) {
+ if (metadataCommitter == null) {
+ return;
+ }
+ switch (metadataCommitter.identifier()) {
+ case "hive":
+ metadataCommitter.commitMetadata(metadataPath,
baseMetadataPath);
+ break;
+ case "rest":
+ metadataCommitter.commitMetadata(metadata, baseMetadata);
+ break;
+ default:
+ throw new UnsupportedOperationException(
+ "Unsupported metadata committer: " +
metadataCommitter.identifier());
+ }
+ }
+
+ /** The newest existing metadata file version, or -1 when there is none. */
+ private long newestExistingMetadataVersion() throws IOException {
+ FileStatus[] statuses;
+ try {
+ statuses =
table.fileIO().listStatus(pathFactory.metadataDirectory());
+ } catch (FileNotFoundException e) {
+ // only a missing directory counts as empty; a transient listing
failure must
+ // fail the commit, or a stale suffix would silently survive
+ return -1;
+ }
+ long newest = -1;
+ for (FileStatus status : statuses) {
+ String name = status.getPath().getName();
+ if (!name.startsWith("v") || !name.endsWith(".metadata.json")) {
+ continue;
+ }
+ try {
+ newest = Math.max(newest, Long.parseLong(name.substring(1,
name.indexOf('.'))));
+ } catch (NumberFormatException ignored) {
+ }
+ }
+ return newest;
+ }
+
+ /** The given metadata file, or null when unreadable. */
+ @Nullable
+ private IcebergMetadata tryReadMetadata(Path metadataPath) {
+ try {
+ return IcebergMetadata.fromPath(table.fileIO(), metadataPath);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private boolean metadataMatchesSnapshot(long snapshotId, Snapshot
snapshot) {
+ try {
+ IcebergMetadata existing =
+ IcebergMetadata.fromPath(
+ table.fileIO(),
pathFactory.toMetadataPath(snapshotId));
+ return metadataMatchesSnapshot(existing, snapshot);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static boolean metadataMatchesSnapshot(IcebergMetadata metadata,
Snapshot snapshot) {
+ if (metadata.currentSnapshot() == null) {
+ return false;
+ }
+ String identity =
+
metadata.currentSnapshot().summary().get(SNAPSHOT_SUMMARY_PAIMON_COMMIT_IDENTITY);
+ return identity == null || identity.equals(commitIdentity(snapshot));
+ }
+
private void createMetadataWithBase(
FileChangesCollector fileChangesCollector,
List<IndexManifestEntry> indexFiles,
Snapshot snapshot,
- Path baseMetadataPath)
+ Path baseMetadataPath,
+ int lastColumnIdFloor)
throws IOException {
long snapshotId = snapshot.id();
IcebergMetadata baseMetadata =
IcebergMetadata.fromPath(table.fileIO(), baseMetadataPath);
+ // a base left on the abandoned timeline must be rebuilt, not extended
+ if (table.snapshotManager().snapshotExists(snapshotId - 1)
+ && !metadataMatchesSnapshot(
+ baseMetadata,
table.snapshotManager().snapshot(snapshotId - 1))) {
+ Long latestNow = table.snapshotManager().latestSnapshotId();
+ if (latestNow == null || latestNow != snapshotId) {
+ // a delayed replay on the abandoned timeline: leave
publication to the head
+ return;
+ }
+ // keep the stale base's identity so external catalogs do not
recreate the table
+ createMetadataWithoutBase(
+ snapshotId,
+ baseMetadata.tableUuid(),
+ Math.max(lastColumnIdFloor, baseMetadata.lastColumnId()));
+ return;
+ }
+
if (!isSameFormatVersion(baseMetadata.formatVersion())) {
// we need to recreate iceberg metadata if format version changed
- createMetadataWithoutBase(snapshot.id());
+ createMetadataWithoutBase(
+ snapshot.id(), null, Math.max(lastColumnIdFloor,
baseMetadata.lastColumnId()));
return;
}
+ // decide the schema story before any manifest is written
+ SchemaCache schemaCache = new SchemaCache();
+ int schemaId = (int) schemaCache.getLatestSchemaId();
+ int snapshotSchemaId = (int) snapshot.schemaId();
+ IcebergSchema icebergSchema = schemaCache.get(schemaId);
+ // re-verified each commit: a rollback re-evolution can redefine an
already
+ // verified id while this callback only ever sees increasing snapshot
ids
+ for (IcebergSchema known : baseMetadata.schemas()) {
+ if (known.schemaId() > schemaId) {
+ continue;
+ }
+ IcebergSchema current =
+ known.schemaId() == schemaId
+ ? icebergSchema
+ : schemaCache.get(known.schemaId());
+ if (!known.equals(current)) {
+ // a re-evolution reused this id with different fields;
rebuild from scratch
+ createMetadataWithoutBase(
+ snapshot.id(),
+ baseMetadata.tableUuid(),
+ Math.max(lastColumnIdFloor,
baseMetadata.lastColumnId()));
+ return;
+ }
+ }
+ if (schemaId < baseMetadata.currentSchemaId()) {
+ // pointer-only schema rollback keeps the base; an
abandoned-timeline base
+ // (snapshot entry mismatching the live snapshot) is rebuilt
+ IcebergSnapshot baseCurrent = baseMetadata.currentSnapshot();
+ SnapshotManager snapshotManager = table.snapshotManager();
+ boolean pointerRollbackOnly =
+ baseCurrent != null
+ && snapshotManager.snapshotExists(snapshotId - 1)
+ && baseCurrent.schemaId()
+ == (int)
snapshotManager.snapshot(snapshotId - 1).schemaId();
+ if (!pointerRollbackOnly) {
+ createMetadataWithoutBase(
+ snapshot.id(),
+ baseMetadata.tableUuid(),
+ Math.max(lastColumnIdFloor,
baseMetadata.lastColumnId()));
+ return;
+ }
+ }
+
List<IcebergManifestFileMeta> baseManifestFileMetas =
manifestList.read(baseMetadata.currentSnapshot().manifestList());
@@ -749,22 +1093,19 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
computeSnapshotSummary(operation, snapshot, metrics);
// add new schemas if needed
- SchemaCache schemaCache = new SchemaCache();
- int schemaId = (int) schemaCache.getLatestSchemaId();
- IcebergSchema icebergSchema = schemaCache.get(schemaId);
List<IcebergSchema> schemas = baseMetadata.schemas();
- if (baseMetadata.currentSchemaId() != schemaId) {
- Preconditions.checkArgument(
- schemaId > baseMetadata.currentSchemaId(),
- "currentSchemaId{%s} in paimon should be greater than
currentSchemaId{%s} in base metadata.",
- schemaId,
- baseMetadata.currentSchemaId());
+ if (schemaId > baseMetadata.currentSchemaId()) {
+ // append only ids the list does not already carry
+ Set<Integer> knownSchemaIds =
+
schemas.stream().map(IcebergSchema::schemaId).collect(Collectors.toSet());
schemas = new ArrayList<>(schemas);
schemas.addAll(
IntStream.rangeClosed(baseMetadata.currentSchemaId() + 1,
schemaId)
+ .filter(id -> !knownSchemaIds.contains(id))
.mapToObj(schemaCache::get)
.collect(Collectors.toList()));
}
+ // a schema-pointer rollback (validated above): only the current
pointer moves
List<IcebergSnapshot> snapshots = new
ArrayList<>(baseMetadata.snapshots());
snapshots.add(
@@ -772,10 +1113,12 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
snapshotId,
snapshotId,
snapshotId - 1,
- System.currentTimeMillis(),
+ // the Paimon snapshot's own commit time, the as-of
time readers see
+ snapshot.timeMillis(),
snapshotSummary,
pathFactory.toManifestListPath(manifestListFileName).toString(),
- schemaId,
+ // the snapshot's own schema, for time travel
+ snapshotSchemaId,
null,
null));
@@ -809,7 +1152,13 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
baseMetadata.tableUuid(),
baseMetadata.location(),
snapshotId,
- icebergSchema.highestFieldId(),
+ // must not regress when the current schema is older
than the base's
+ // never below what the replaced metadata already
handed out
+ Math.max(
+ lastColumnIdFloor,
+ Math.max(
+ baseMetadata.lastColumnId(),
+ icebergSchema.highestFieldId())),
schemas,
schemaId,
baseMetadata.partitionSpecs(),
@@ -819,30 +1168,34 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
refs);
Path metadataPath = pathFactory.toMetadataPath(snapshotId);
- table.fileIO().tryToWriteAtomic(metadataPath, metadata.toJson());
- table.fileIO()
- .overwriteFileUtf8(
- new Path(pathFactory.metadataDirectory(),
VERSION_HINT_FILENAME),
- String.valueOf(snapshotId));
-
- deleteApplicableMetadataFiles(snapshotId);
- for (int i = 0; i + 1 < toExpireExceptLast.size(); i++) {
- expireManifestList(
- new
Path(toExpireExceptLast.get(i).manifestList()).getName(),
- new Path(toExpireExceptLast.get(i +
1).manifestList()).getName());
+ // atomic-first: see the no-base path
+ boolean written = table.fileIO().tryToWriteAtomic(metadataPath,
metadata.toJson());
+ if (!written
+ && table.fileIO().exists(metadataPath)
+ && !metadataMatchesSnapshot(snapshotId, snapshot)) {
+ table.fileIO().deleteQuietly(metadataPath);
+ written = table.fileIO().tryToWriteAtomic(metadataPath,
metadata.toJson());
}
-
- if (metadataCommitter != null) {
- switch (metadataCommitter.identifier()) {
- case "hive":
- metadataCommitter.commitMetadata(metadataPath,
baseMetadataPath);
- break;
- case "rest":
- metadataCommitter.commitMetadata(metadata, baseMetadata);
- break;
- default:
- throw new UnsupportedOperationException(
- "Unsupported metadata committer: " +
metadataCommitter.identifier());
+ if (!written && !metadataMatchesSnapshot(snapshotId, snapshot)) {
+ // no twin published this snapshot's metadata; fail so the commit
retries
+ throw new IllegalStateException("Failed to replace Iceberg
metadata " + metadataPath);
+ }
+ // a delayed callback may still write its metadata (a newer commit
extends it), but
+ // only the current head may move the hint and the external catalog
+ Long latestAtPublish = table.snapshotManager().latestSnapshotId();
+ if (latestAtPublish != null && latestAtPublish == snapshotId) {
+ table.fileIO()
+ .overwriteFileUtf8(
+ new Path(pathFactory.metadataDirectory(),
VERSION_HINT_FILENAME),
+ String.valueOf(snapshotId));
+ commitToExternalCatalog(metadata, metadataPath, baseMetadata,
baseMetadataPath);
+ // cleanup only after the catalog serves the new head: a skipped
or failed
+ // publication must not delete files an external pointer still
references
+ deleteApplicableMetadataFiles(snapshotId);
+ for (int i = 0; i + 1 < toExpireExceptLast.size(); i++) {
+ expireManifestList(
+ new
Path(toExpireExceptLast.get(i).manifestList()).getName(),
+ new Path(toExpireExceptLast.get(i +
1).manifestList()).getName());
}
}
}
@@ -1439,6 +1792,24 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
long totalEqualityDeletes;
}
+ /**
+ * Summary entry identifying the Paimon snapshot this metadata was built
from; it tells live
+ * metadata from metadata a rollback abandoned.
+ */
+ static final String SNAPSHOT_SUMMARY_PAIMON_COMMIT_IDENTITY =
"paimon-commit-identity";
+
+ private static String commitIdentity(Snapshot snapshot) {
+ // snapshot uuid when present; legacy snapshots fall back to
user/identifier/time
+ if (snapshot.uuid() != null) {
+ return snapshot.uuid();
+ }
+ return snapshot.commitUser()
+ + ":"
+ + snapshot.commitIdentifier()
+ + ":"
+ + snapshot.timeMillis();
+ }
+
private IcebergSnapshotSummary computeSnapshotSummary(
String operation, Snapshot snapshot, SummaryMetrics metrics) {
@@ -1481,6 +1852,8 @@ public class IcebergCommitCallback implements
CommitCallback, TagCallback {
}
});
}
+ // after the user-property copy, so a same-key property cannot
overwrite it
+ summary.put(SNAPSHOT_SUMMARY_PAIMON_COMMIT_IDENTITY,
commitIdentity(snapshot));
return summary;
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
b/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
index cf8388fc55..2f893d4e3b 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/AbstractFileStoreTable.java
@@ -24,6 +24,7 @@ import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.consumer.ConsumerManager;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.iceberg.IcebergCommitCallback;
import org.apache.paimon.manifest.IndexManifestEntry;
import org.apache.paimon.manifest.ManifestEntry;
import org.apache.paimon.manifest.ManifestFileMeta;
@@ -518,6 +519,7 @@ abstract class AbstractFileStoreTable implements
FileStoreTable {
@Override
public void rollbackTo(long snapshotId) {
+ IcebergCommitCallback.markRetirePendingForRollback(this);
SnapshotManager snapshotManager = snapshotManager();
try {
snapshotManager.rollback(Instant.snapshot(snapshotId));
@@ -543,6 +545,7 @@ abstract class AbstractFileStoreTable implements
FileStoreTable {
@Override
public void rollbackTo(String tagName) {
+ IcebergCommitCallback.markRetirePendingForRollback(this);
SnapshotManager snapshotManager = snapshotManager();
try {
snapshotManager.rollback(Instant.tag(tagName));
diff --git
a/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
b/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
index c8a1b3fa4c..d8a22aaee4 100644
---
a/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/iceberg/IcebergCompatibilityTest.java
@@ -40,6 +40,10 @@ 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.IcebergRef;
+import org.apache.paimon.iceberg.metadata.IcebergSchema;
+import org.apache.paimon.iceberg.metadata.IcebergSnapshot;
+import org.apache.paimon.manifest.ManifestCommittable;
+import org.apache.paimon.options.ExpireConfig;
import org.apache.paimon.options.MemorySize;
import org.apache.paimon.options.Options;
import org.apache.paimon.schema.Schema;
@@ -56,6 +60,8 @@ import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowKind;
import org.apache.paimon.types.RowType;
+import
org.apache.paimon.shade.caffeine2.com.github.benmanes.caffeine.cache.Caffeine;
+
import org.apache.avro.Schema.Field;
import org.apache.avro.Schema.Type;
import org.apache.avro.file.DataFileReader;
@@ -456,6 +462,420 @@ public class IcebergCompatibilityTest {
commit.close();
}
+ @Test
+ public void testCommitAfterRollbackDoesNotDuplicateSchemas() 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);
+
+ 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(false, 1));
+ table.createTag("before-evolution", 1);
+ write.close();
+ commit.close();
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("w",
DataTypes.INT()));
+ table = table.copyWithLatestSchema();
+ write = table.newWrite(commitUser);
+ commit = table.newCommit(commitUser);
+ write.write(GenericRow.of(2, 20, 200));
+ commit.commit(2, write.prepareCommit(false, 2));
+ write.close();
+ commit.close();
+
+ TableCommitImpl rollbackCommit = table.newCommit(commitUser);
+
rollbackCommit.rollbackToAsLatest(table.tagManager().getOrThrow("before-evolution"));
+ rollbackCommit.close();
+
+ write = table.newWrite(commitUser);
+ commit = table.newCommit(commitUser);
+ write.write(GenericRow.of(3, 30, 300));
+ commit.commit(3, write.prepareCommit(false, 3));
+ write.close();
+ commit.close();
+
+ long latestId = table.snapshotManager().latestSnapshotId();
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ IcebergMetadata metadata =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(latestId));
+ List<Integer> schemaIds =
+ metadata.schemas().stream()
+ .map(IcebergSchema::schemaId)
+ .collect(Collectors.toList());
+ assertThat(schemaIds).doesNotHaveDuplicates();
+ assertThat(metadata.currentSchemaId())
+ .isEqualTo((int)
table.snapshotManager().snapshot(latestId).schemaId());
+ }
+
+ @Test
+ public void testRollbackSnapshotRecordsItsOwnSchema() 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);
+
+ 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(false, 1));
+ table.createTag("before-evolution", 1);
+ write.close();
+ commit.close();
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("w",
DataTypes.INT()));
+ table = table.copyWithLatestSchema();
+ write = table.newWrite(commitUser);
+ commit = table.newCommit(commitUser);
+ write.write(GenericRow.of(2, 20, 200));
+ commit.commit(2, write.prepareCommit(false, 2));
+ int evolvedSchemaId = (int)
table.snapshotManager().snapshot(2).schemaId();
+ int evolvedLastColumnId =
+ IcebergMetadata.fromPath(
+ table.fileIO(),
+ new IcebergPathFactory(new
Path(table.location(), "metadata"))
+ .toMetadataPath(2))
+ .lastColumnId();
+ write.close();
+ commit.close();
+
+ TableCommitImpl rollbackCommit = table.newCommit(commitUser);
+
rollbackCommit.rollbackToAsLatest(table.tagManager().getOrThrow("before-evolution"));
+ rollbackCommit.close();
+ long rolledBackId = table.snapshotManager().latestSnapshotId();
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ IcebergMetadata rebuilt =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(rolledBackId));
+ assertThat(rebuilt.currentSchemaId()).isEqualTo(evolvedSchemaId);
+ assertThat(rebuilt.currentSnapshot().schemaId())
+ .isEqualTo((int)
table.snapshotManager().snapshot(rolledBackId).schemaId());
+
assertThat(rebuilt.currentSnapshot().schemaId()).isLessThan(evolvedSchemaId);
+
assertThat(rebuilt.lastColumnId()).isGreaterThanOrEqualTo(evolvedLastColumnId);
+ }
+
+ @Test
+ public void testBaseLessRebuildRecordsRollbackSnapshotSchema() 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);
+
+ 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(false, 1));
+ table.createTag("before-evolution", 1);
+ write.close();
+ commit.close();
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("w",
DataTypes.INT()));
+ FileStoreTable evolved = table.copyWithLatestSchema();
+ TableWriteImpl<?> write2 = evolved.newWrite(commitUser);
+ TableCommitImpl commit2 = evolved.newCommit(commitUser);
+ write2.write(GenericRow.of(2, 20, 200));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ for (org.apache.paimon.fs.FileStatus st :
+ table.fileIO().listStatus(pathFactory.metadataDirectory())) {
+ if (st.getPath().getName().endsWith(".metadata.json")) {
+ table.fileIO().deleteQuietly(st.getPath());
+ }
+ }
+
+ TableCommitImpl rollbackCommit = evolved.newCommit(commitUser);
+
rollbackCommit.rollbackToAsLatest(evolved.tagManager().getOrThrow("before-evolution"));
+ rollbackCommit.close();
+
+ long latestId = table.snapshotManager().latestSnapshotId();
+ IcebergMetadata rebuilt =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(latestId));
+ assertThat(rebuilt.currentSchemaId()).isEqualTo(1);
+ assertThat(rebuilt.currentSnapshot().schemaId())
+ .isEqualTo((int)
table.snapshotManager().snapshot(latestId).schemaId());
+ assertThat(rebuilt.schemas()).anyMatch(s -> s.schemaId() == 1);
+ assertThat(rebuilt.lastColumnId())
+ .isEqualTo(
+ rebuilt.schemas().stream()
+ .mapToInt(IcebergSchema::highestFieldId)
+ .max()
+ .getAsInt());
+ }
+
+ @Test
+ public void testBaseLessRebuildKeepsLastColumnIdAboveDroppedFields()
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);
+
+ 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(false, 1));
+ write.close();
+ commit.close();
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("w",
DataTypes.INT()));
+ schemaManager.commitChanges(SchemaChange.dropColumn("w"));
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ for (org.apache.paimon.fs.FileStatus st :
+ table.fileIO().listStatus(pathFactory.metadataDirectory())) {
+ if (st.getPath().getName().endsWith(".metadata.json")) {
+ table.fileIO().deleteQuietly(st.getPath());
+ }
+ }
+
+ FileStoreTable latest = table.copyWithLatestSchema();
+ TableWriteImpl<?> write2 = latest.newWrite(commitUser);
+ TableCommitImpl commit2 = latest.newCommit(commitUser);
+ write2.write(GenericRow.of(2, 20));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ long latestId = table.snapshotManager().latestSnapshotId();
+ IcebergMetadata rebuilt =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(latestId));
+ int maxFieldId =
+
rebuilt.schemas().stream().mapToInt(IcebergSchema::highestFieldId).max().getAsInt();
+ assertThat(rebuilt.lastColumnId()).isGreaterThanOrEqualTo(maxFieldId);
+ }
+
+ @Test
+ public void testSchemaPointerRollbackKeepsHistory() 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);
+
+ 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(false, 1));
+ table.createTag("before-evolution", 1);
+ write.close();
+ commit.close();
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("w",
DataTypes.INT()));
+ FileStoreTable evolved = table.copyWithLatestSchema();
+ TableWriteImpl<?> write2 = evolved.newWrite(commitUser);
+ TableCommitImpl commit2 = evolved.newCommit(commitUser);
+ write2.write(GenericRow.of(2, 20, 200));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ TableCommitImpl rollbackCommit = evolved.newCommit(commitUser);
+
rollbackCommit.rollbackToAsLatest(evolved.tagManager().getOrThrow("before-evolution"));
+ rollbackCommit.close();
+ table.deleteTag("before-evolution");
+ table.newExpireSnapshots()
+
.config(ExpireConfig.builder().snapshotRetainMax(1).snapshotRetainMin(1).build())
+ .expire();
+ schemaManager.rollbackTo(
+ 0, table.snapshotManager(), table.tagManager(),
table.changelogManager());
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ long rollbackId = table.snapshotManager().latestSnapshotId();
+ String uuidBefore =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(rollbackId))
+ .tableUuid();
+
+ TableWriteImpl<?> write3 = table.newWrite(commitUser);
+ TableCommitImpl commit3 = table.newCommit(commitUser);
+ write3.write(GenericRow.of(3, 30));
+ commit3.commit(3, write3.prepareCommit(false, 3));
+ write3.close();
+ commit3.close();
+
+ long latestId = table.snapshotManager().latestSnapshotId();
+ IcebergMetadata metadata =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(latestId));
+ assertThat(metadata.tableUuid()).isEqualTo(uuidBefore);
+ assertThat(metadata.currentSchemaId()).isEqualTo(0);
+
assertThat(metadata.snapshots().stream().map(IcebergSnapshot::snapshotId))
+ .contains(rollbackId, latestId);
+ }
+
+ @Test
+ public void testSchemaRollbackWithAbandonedBaseRebuildsMetadata() 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);
+
+ 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(false, 1));
+ write.close();
+ commit.close();
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("w",
DataTypes.INT()));
+ FileStoreTable evolved = table.copyWithLatestSchema();
+ TableWriteImpl<?> write2 = evolved.newWrite(commitUser);
+ TableCommitImpl commit2 = evolved.newCommit(commitUser);
+ write2.write(GenericRow.of(2, 20, 200));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ table.rollbackTo(1);
+ schemaManager.rollbackTo(
+ 0, table.snapshotManager(), table.tagManager(),
table.changelogManager());
+
+ TableWriteImpl<?> write3 = table.newWrite(commitUser);
+ TableCommitImpl commit3 = table.newCommit(commitUser);
+ write3.write(GenericRow.of(3, 30));
+ commit3.commit(2, write3.prepareCommit(false, 2));
+ write3.write(GenericRow.of(4, 40));
+ commit3.commit(3, write3.prepareCommit(false, 3));
+ write3.close();
+ commit3.close();
+
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1, 10)", "Record(3, 30)",
"Record(4, 40)");
+ }
+
+ @Test
+ public void testReusedSchemaIdAfterSchemaRollbackRebuildsMetadata() 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);
+
+ 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(false, 1));
+ write.close();
+ commit.close();
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("w",
DataTypes.INT()));
+ FileStoreTable evolved = table.copyWithLatestSchema();
+ TableWriteImpl<?> write2 = evolved.newWrite(commitUser);
+ TableCommitImpl commit2 = evolved.newCommit(commitUser);
+ write2.write(GenericRow.of(2, 20, 200));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ table.rollbackTo(1);
+ schemaManager.rollbackTo(
+ 0, table.snapshotManager(), table.tagManager(),
table.changelogManager());
+ schemaManager.commitChanges(SchemaChange.addColumn("x",
DataTypes.STRING()));
+
+ FileStoreTable reEvolved = table.copyWithLatestSchema();
+ TableWriteImpl<?> write3 = reEvolved.newWrite(commitUser);
+ TableCommitImpl commit3 = reEvolved.newCommit(commitUser);
+ write3.write(GenericRow.of(3, 30, BinaryString.fromString("three")));
+ commit3.commit(2, write3.prepareCommit(false, 2));
+ write3.write(GenericRow.of(4, 40, BinaryString.fromString("four")));
+ commit3.commit(3, write3.prepareCommit(false, 3));
+ write3.close();
+ commit3.close();
+
+ long latestId = table.snapshotManager().latestSnapshotId();
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ IcebergMetadata metadata =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(latestId));
+ assertThat(
+ metadata.schemas().stream()
+ .filter(sch -> sch.schemaId() == 1)
+ .flatMap(sch -> sch.fields().stream())
+ .map(f -> f.name()))
+ .contains("x")
+ .doesNotContain("w");
+ }
+
+ @Test
+ public void testRebuildAfterSchemaRollbackKeepsLastColumnIdHighWaterMark()
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);
+
+ 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(false, 1));
+ write.close();
+ commit.close();
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("w",
DataTypes.INT()));
+ schemaManager.commitChanges(SchemaChange.addColumn("y",
DataTypes.INT()));
+ FileStoreTable evolved = table.copyWithLatestSchema();
+ TableWriteImpl<?> write2 = evolved.newWrite(commitUser);
+ TableCommitImpl commit2 = evolved.newCommit(commitUser);
+ write2.write(GenericRow.of(2, 20, 200, 2000));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ int oldLastColumnId =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(2))
+ .lastColumnId();
+
+ table.rollbackTo(1);
+ schemaManager.rollbackTo(
+ 0, table.snapshotManager(), table.tagManager(),
table.changelogManager());
+ schemaManager.commitChanges(SchemaChange.addColumn("x",
DataTypes.STRING()));
+
+ FileStoreTable reEvolved = table.copyWithLatestSchema();
+ TableWriteImpl<?> write3 = reEvolved.newWrite(commitUser);
+ TableCommitImpl commit3 = reEvolved.newCommit(commitUser);
+ write3.write(GenericRow.of(3, 30, BinaryString.fromString("three")));
+ commit3.commit(2, write3.prepareCommit(false, 2));
+ write3.write(GenericRow.of(4, 40, BinaryString.fromString("four")));
+ commit3.commit(3, write3.prepareCommit(false, 3));
+ write3.close();
+ commit3.close();
+
+ long latestId = table.snapshotManager().latestSnapshotId();
+ IcebergMetadata rebuilt =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(latestId));
+
assertThat(rebuilt.lastColumnId()).isGreaterThanOrEqualTo(oldLastColumnId);
+ }
+
@Test
public void testSchemaChange() throws Exception {
RowType rowType =
@@ -1685,6 +2105,437 @@ public class IcebergCompatibilityTest {
}
}
+ @Test
+ public void
testDeepRollbackRetiresAbandonedSuffixWhenReusedVersionExpired() 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);
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = table.newWrite(commitUser);
+ TableCommitImpl commit = table.newCommit(commitUser);
+ for (int i = 1; i <= 4; i++) {
+ write.write(GenericRow.of(i, i * 10));
+ commit.commit(i, write.prepareCommit(false, i));
+ }
+ write.close();
+ commit.close();
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(2))).isFalse();
+ String uuidBefore =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(4)).tableUuid();
+
+ table.rollbackTo(1);
+ TableWriteImpl<?> write2 = table.newWrite(commitUser);
+ TableCommitImpl commit2 = table.newCommit(commitUser);
+ write2.write(GenericRow.of(9, 90));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(3))).isFalse();
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(4))).isFalse();
+ assertThat(getIcebergResult()).containsExactlyInAnyOrder("Record(1,
10)", "Record(9, 90)");
+ long latestId = table.snapshotManager().latestSnapshotId();
+ assertThat(
+ IcebergMetadata.fromPath(
+ table.fileIO(),
pathFactory.toMetadataPath(latestId))
+ .tableUuid())
+ .isEqualTo(uuidBefore);
+ }
+
+ @Test
+ public void testRetryRecommitsMetadataToExternalCatalog() throws Exception
{
+ RecordingIcebergMetadataCommitter.COMMITS.clear();
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.INT(), DataTypes.INT()}, new
String[] {"k", "v"});
+ FileStoreTable table =
+ createPaimonTable(rowType, Collections.emptyList(),
Collections.emptyList(), -1)
+ .copy(
+ Collections.singletonMap(
+
IcebergOptions.METADATA_ICEBERG_STORAGE.key(),
+
IcebergOptions.StorageType.HADOOP_CATALOG.toString()));
+
+ 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(false, 1));
+ write.write(GenericRow.of(2, 20));
+ commit.commit(2, write.prepareCommit(false, 2));
+ write.close();
+ commit.close();
+ assertThat(RecordingIcebergMetadataCommitter.COMMITS).isNotEmpty();
+
+ RecordingIcebergMetadataCommitter.COMMITS.clear();
+ IcebergCommitCallback callback = new IcebergCommitCallback(table,
commitUser);
+ callback.retry(new ManifestCommittable(2));
+ callback.close();
+ assertThat(RecordingIcebergMetadataCommitter.COMMITS).isNotEmpty();
+ }
+
+ @Test
+ public void testRetryOfOldSnapshotDoesNotMoveCatalogPointer() throws
Exception {
+ RecordingIcebergMetadataCommitter.COMMITS.clear();
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.INT(), DataTypes.INT()}, new
String[] {"k", "v"});
+ FileStoreTable table =
+ createPaimonTable(rowType, Collections.emptyList(),
Collections.emptyList(), -1)
+ .copy(
+ Collections.singletonMap(
+
IcebergOptions.METADATA_ICEBERG_STORAGE.key(),
+
IcebergOptions.StorageType.HADOOP_CATALOG.toString()));
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = table.newWrite(commitUser);
+ TableCommitImpl commit = table.newCommit(commitUser);
+ for (int i = 1; i <= 3; i++) {
+ write.write(GenericRow.of(i, i * 10));
+ commit.commit(i, write.prepareCommit(false, i));
+ }
+ write.close();
+ commit.close();
+ assertThat(RecordingIcebergMetadataCommitter.COMMITS).isNotEmpty();
+
+ RecordingIcebergMetadataCommitter.COMMITS.clear();
+ IcebergCommitCallback callback = new IcebergCommitCallback(table,
commitUser);
+ callback.retry(new ManifestCommittable(2));
+ callback.close();
+ assertThat(RecordingIcebergMetadataCommitter.COMMITS).isEmpty();
+ }
+
+ @Test
+ public void testDeepRollbackRetiresSuffixWhenVersionHintLagsBehind()
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);
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = table.newWrite(commitUser);
+ TableCommitImpl commit = table.newCommit(commitUser);
+ for (int i = 1; i <= 4; i++) {
+ write.write(GenericRow.of(i, i * 10));
+ commit.commit(i, write.prepareCommit(false, i));
+ }
+ write.close();
+ commit.close();
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(2))).isFalse();
+ String uuidBefore =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(4)).tableUuid();
+
+ table.rollbackTo(1);
+ table.fileIO()
+ .overwriteFileUtf8(
+ new Path(pathFactory.metadataDirectory(),
"version-hint.text"), "1");
+ TableWriteImpl<?> write2 = table.newWrite(commitUser);
+ TableCommitImpl commit2 = table.newCommit(commitUser);
+ write2.write(GenericRow.of(9, 90));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(3))).isFalse();
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(4))).isFalse();
+ long latestId = table.snapshotManager().latestSnapshotId();
+ assertThat(
+ IcebergMetadata.fromPath(
+ table.fileIO(),
pathFactory.toMetadataPath(latestId))
+ .tableUuid())
+ .isEqualTo(uuidBefore);
+ }
+
+ @Test
+ public void testDelayedReplayOfReusedSnapshotDoesNotMoveHeadBack() 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);
+
+ 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(false, 1));
+ write.write(GenericRow.of(2, 20));
+ commit.commit(2, write.prepareCommit(false, 2));
+ write.close();
+ commit.close();
+
+ table.rollbackTo(1);
+
+ FileStoreTable disabled =
+ table.copy(
+ Collections.singletonMap(
+ IcebergOptions.METADATA_ICEBERG_STORAGE.key(),
"disabled"));
+ TableWriteImpl<?> write2 = disabled.newWrite(commitUser);
+ TableCommitImpl commit2 = disabled.newCommit(commitUser);
+ write2.write(GenericRow.of(3, 30));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ TableWriteImpl<?> write3 = table.newWrite(commitUser);
+ TableCommitImpl commit3 = table.newCommit(commitUser);
+ write3.write(GenericRow.of(4, 40));
+ commit3.commit(3, write3.prepareCommit(false, 3));
+ write3.close();
+ commit3.close();
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+ String hintBefore =
+ table.fileIO()
+ .readFileUtf8(
+ new Path(pathFactory.metadataDirectory(),
"version-hint.text"))
+ .trim();
+ assertThat(hintBefore).isEqualTo("3");
+
+ IcebergCommitCallback callback = new IcebergCommitCallback(table,
commitUser);
+ callback.retry(new ManifestCommittable(2));
+ callback.close();
+
+ assertThat(
+ table.fileIO()
+ .readFileUtf8(
+ new Path(
+
pathFactory.metadataDirectory(),
+ "version-hint.text"))
+ .trim())
+ .isEqualTo("3");
+ }
+
+ @Test
+ public void testRetryAfterCatalogFailureKeepsItsBase() throws Exception {
+ RecordingIcebergMetadataCommitter.COMMITS.clear();
+ RecordingIcebergMetadataCommitter.BASES.clear();
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.INT(), DataTypes.INT()}, new
String[] {"k", "v"});
+ Map<String, String> overrides = new HashMap<>();
+ overrides.put(
+ IcebergOptions.METADATA_ICEBERG_STORAGE.key(),
+ IcebergOptions.StorageType.HADOOP_CATALOG.toString());
+ overrides.put(IcebergOptions.METADATA_PREVIOUS_VERSIONS_MAX.key(),
"0");
+ FileStoreTable table =
+ createPaimonTable(rowType, Collections.emptyList(),
Collections.emptyList(), -1)
+ .copy(overrides);
+
+ 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(false, 1));
+
+ RecordingIcebergMetadataCommitter.failNextCommit = true;
+ write.write(GenericRow.of(2, 20));
+ assertThatThrownBy(() -> commit.commit(2, write.prepareCommit(false,
2)))
+ .hasStackTraceContaining("injected catalog failure");
+ write.close();
+ try {
+ commit.close();
+ } catch (Exception ignored) {
+ }
+
+ RecordingIcebergMetadataCommitter.BASES.clear();
+ IcebergCommitCallback callback = new IcebergCommitCallback(table,
commitUser);
+ callback.retry(new ManifestCommittable(2));
+ callback.close();
+ assertThat(RecordingIcebergMetadataCommitter.BASES).hasSize(1);
+ assertThat(RecordingIcebergMetadataCommitter.BASES.get(0)).isNotNull();
+
+ IcebergPathFactory pathFactory =
+ new
IcebergPathFactory(IcebergCommitCallback.catalogTableMetadataPath(table));
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(1))).isFalse();
+ }
+
+ @Test
+ public void testDeepRollbackRecoversUuidWhenVersionHintMissing() 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);
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = table.newWrite(commitUser);
+ TableCommitImpl commit = table.newCommit(commitUser);
+ for (int i = 1; i <= 4; i++) {
+ write.write(GenericRow.of(i, i * 10));
+ commit.commit(i, write.prepareCommit(false, i));
+ }
+ write.close();
+ commit.close();
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(2))).isFalse();
+ String uuidBefore =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(4)).tableUuid();
+
+ table.rollbackTo(1);
+ table.fileIO()
+ .deleteQuietly(new Path(pathFactory.metadataDirectory(),
"version-hint.text"));
+ TableWriteImpl<?> write2 = table.newWrite(commitUser);
+ TableCommitImpl commit2 = table.newCommit(commitUser);
+ write2.write(GenericRow.of(9, 90));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(3))).isFalse();
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(4))).isFalse();
+ long latestId = table.snapshotManager().latestSnapshotId();
+ assertThat(
+ IcebergMetadata.fromPath(
+ table.fileIO(),
pathFactory.toMetadataPath(latestId))
+ .tableUuid())
+ .isEqualTo(uuidBefore);
+ }
+
+ @Test
+ public void testRecommitAfterRollbackIgnoresStaleSnapshotCache() throws
Exception {
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.INT(), DataTypes.INT()}, new
String[] {"k", "v"});
+ FileStoreTable stale =
+ createPaimonTable(rowType, Collections.emptyList(),
Collections.emptyList(), -1);
+ stale.setSnapshotCache(Caffeine.newBuilder().maximumSize(128).build());
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = stale.newWrite(commitUser);
+ TableCommitImpl commit = stale.newCommit(commitUser);
+ for (int i = 1; i <= 4; i++) {
+ write.write(GenericRow.of(i, i * 10));
+ commit.commit(i, write.prepareCommit(false, i));
+ }
+ write.close();
+ commit.close();
+ stale.snapshotManager().snapshot(2);
+
+ try (FileSystemCatalog freshCatalog =
+ new FileSystemCatalog(LocalFileIO.create(), new
Path(tempDir.toString()))) {
+ FileStoreTable fresh =
+ (FileStoreTable)
freshCatalog.getTable(Identifier.create("mydb", "t"));
+ fresh.rollbackTo(1);
+ }
+
+ TableWriteImpl<?> write2 = stale.newWrite(commitUser);
+ TableCommitImpl commit2 = stale.newCommit(commitUser);
+ write2.write(GenericRow.of(9, 90));
+ commit2.commit(5, write2.prepareCommit(false, 5));
+ write2.close();
+ commit2.close();
+
+ assertThat(getIcebergResult()).containsExactlyInAnyOrder("Record(1,
10)", "Record(9, 90)");
+ }
+
+ @Test
+ public void testRecommitAfterRollbackReplacesStaleMetadata() 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);
+
+ 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(false, 1));
+ write.write(GenericRow.of(2, 20));
+ commit.commit(2, write.prepareCommit(false, 2));
+ write.close();
+ commit.close();
+
+ TableWriteImpl<?> writeExtra = table.newWrite(commitUser);
+ TableCommitImpl commitExtra = table.newCommit(commitUser);
+ writeExtra.write(GenericRow.of(9, 90));
+ commitExtra.commit(3, writeExtra.prepareCommit(false, 3));
+ writeExtra.close();
+ commitExtra.close();
+
+ table.rollbackTo(1);
+ TableWriteImpl<?> write2 = table.newWrite(commitUser);
+ TableCommitImpl commit2 = table.newCommit(commitUser);
+ write2.write(GenericRow.of(3, 30));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ IcebergPathFactory suffixPathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+
assertThat(table.fileIO().exists(suffixPathFactory.toMetadataPath(3))).isFalse();
+ assertThat(getIcebergResult()).containsExactlyInAnyOrder("Record(1,
10)", "Record(3, 30)");
+
+ TableWriteImpl<?> write3 = table.newWrite(commitUser);
+ TableCommitImpl commit3 = table.newCommit(commitUser);
+ write3.write(GenericRow.of(4, 40));
+ commit3.commit(3, write3.prepareCommit(false, 3));
+ write3.close();
+ commit3.close();
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1, 10)", "Record(3, 30)",
"Record(4, 40)");
+ }
+
+ @Test
+ public void testRollbackMarksRetirementAcrossVersionGaps() 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)
+ .copy(
+ Collections.singletonMap(
+
IcebergOptions.METADATA_PREVIOUS_VERSIONS_MAX.key(), "0"));
+
+ String commitUser = UUID.randomUUID().toString();
+ TableWriteImpl<?> write = table.newWrite(commitUser);
+ TableCommitImpl commit = table.newCommit(commitUser);
+ for (int i = 1; i <= 4; i++) {
+ write.write(GenericRow.of(i, i * 10));
+ commit.commit(i, write.prepareCommit(false, i));
+ }
+ write.close();
+ commit.close();
+
+ IcebergPathFactory pathFactory =
+ new IcebergPathFactory(new Path(table.location(), "metadata"));
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(4))).isTrue();
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(3))).isFalse();
+ String uuidBefore =
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(4)).tableUuid();
+
+ table.fileIO()
+ .overwriteFileUtf8(
+ new Path(pathFactory.metadataDirectory(),
"version-hint.text"), "1");
+ table.rollbackTo(1);
+
+ TableWriteImpl<?> write2 = table.newWrite(commitUser);
+ TableCommitImpl commit2 = table.newCommit(commitUser);
+ write2.write(GenericRow.of(9, 90));
+ commit2.commit(5, write2.prepareCommit(false, 5));
+ write2.close();
+ commit2.close();
+
+
assertThat(table.fileIO().exists(pathFactory.toMetadataPath(4))).isFalse();
+ assertThat(
+ IcebergMetadata.fromPath(table.fileIO(),
pathFactory.toMetadataPath(2))
+ .tableUuid())
+ .isEqualTo(uuidBefore);
+ }
+
// ------------------------------------------------------------------------
// Utils
// ------------------------------------------------------------------------
diff --git
a/paimon-core/src/test/java/org/apache/paimon/iceberg/RecordingIcebergMetadataCommitter.java
b/paimon-core/src/test/java/org/apache/paimon/iceberg/RecordingIcebergMetadataCommitter.java
new file mode 100644
index 0000000000..f83de7aed5
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/iceberg/RecordingIcebergMetadataCommitter.java
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.iceberg;
+
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.iceberg.metadata.IcebergMetadata;
+import org.apache.paimon.table.FileStoreTable;
+
+import javax.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/** An {@link IcebergMetadataCommitter} recording every commit, for tests. */
+public class RecordingIcebergMetadataCommitter implements
IcebergMetadataCommitter {
+
+ public static final List<Object> COMMITS =
Collections.synchronizedList(new ArrayList<>());
+ public static final List<Object> BASES = Collections.synchronizedList(new
ArrayList<>());
+ public static volatile boolean failNextCommit = false;
+
+ private static void maybeFail() {
+ if (failNextCommit) {
+ failNextCommit = false;
+ throw new RuntimeException("injected catalog failure");
+ }
+ }
+
+ @Override
+ public String identifier() {
+ return "hive";
+ }
+
+ @Override
+ public void commitMetadata(Path newMetadataPath, @Nullable Path
baseMetadataPath) {
+ maybeFail();
+ COMMITS.add(newMetadataPath);
+ BASES.add(baseMetadataPath);
+ }
+
+ @Override
+ public void commitMetadata(
+ IcebergMetadata newIcebergMetadata, @Nullable IcebergMetadata
baseIcebergMetadata) {
+ maybeFail();
+ COMMITS.add(newIcebergMetadata);
+ BASES.add(baseIcebergMetadata);
+ }
+
+ /** Registered under hadoop-catalog: no real committer exists there, so no
ambiguity. */
+ public static class Factory implements IcebergMetadataCommitterFactory {
+
+ @Override
+ public String identifier() {
+ return IcebergOptions.StorageType.HADOOP_CATALOG.toString();
+ }
+
+ @Override
+ public IcebergMetadataCommitter create(FileStoreTable table) {
+ return new RecordingIcebergMetadataCommitter();
+ }
+ }
+}
diff --git
a/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
b/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
index 76ffd7a52f..c48309213e 100644
---
a/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
+++
b/paimon-core/src/test/resources/META-INF/services/org.apache.paimon.factories.Factory
@@ -16,3 +16,4 @@
org.apache.paimon.mergetree.compact.aggregate.TestCustomAggFactory
org.apache.paimon.mergetree.compact.aggregate.TestMapOnlyAggFactory
org.apache.paimon.rest.auth.CustomTestDLFTokenLoaderFactory
+org.apache.paimon.iceberg.RecordingIcebergMetadataCommitter$Factory
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 9dbc796701..2357a989c9 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
@@ -71,6 +71,8 @@ import static
org.apache.iceberg.TableProperties.METADATA_PREVIOUS_VERSIONS_MAX;
*/
public class IcebergRestMetadataCommitter implements IcebergMetadataCommitter {
+ private static final String PAIMON_COMMIT_IDENTITY =
"paimon-commit-identity";
+
private static final Logger LOG =
LoggerFactory.getLogger(IcebergRestMetadataCommitter.class);
private static final String REST_CATALOG_NAME = "rest-catalog";
@@ -160,6 +162,23 @@ public class IcebergRestMetadataCommitter implements
IcebergMetadataCommitter {
TableMetadata metadata = ((BaseTable)
icebergTable).operations().current();
+ org.apache.iceberg.Snapshot catalogCurrent =
metadata.currentSnapshot();
+ org.apache.iceberg.Snapshot newCurrent =
newMetadata.currentSnapshot();
+ if (catalogCurrent != null
+ && newCurrent != null
+ && catalogCurrent.snapshotId() ==
newCurrent.snapshotId()
+ && java.util.Objects.equals(
+
catalogCurrent.summary().get(PAIMON_COMMIT_IDENTITY),
+
newCurrent.summary().get(PAIMON_COMMIT_IDENTITY))) {
+ // an idempotent retry: the catalog is already at this
snapshot; rebuilding
+ // through updatesForIncorrectBase would drop and recreate
the table
+ LOG.info(
+ "Iceberg table {} is already at snapshot {},
nothing to commit.",
+ icebergTableIdentifier,
+ newCurrent.snapshotId());
+ return;
+ }
+
if (metadata.currentSnapshot() == null) {
// Table exists in the REST catalog but has no snapshots
yet. This happens
// when a previous createTable() or recreateTable()
succeeded but the
@@ -229,19 +248,16 @@ public class IcebergRestMetadataCommitter implements
IcebergMetadataCommitter {
} else {
// add new schema if needed
- Preconditions.checkArgument(
- newMetadata.currentSchemaId() >= schemaId,
- "the new metadata has correct base, but the schemaId(%s)
in iceberg table "
- + "is greater than currentSchemaId(%s) in new
metadata.",
- schemaId,
- newMetadata.currentSchemaId());
- if (newMetadata.currentSchemaId() != schemaId) {
+ if (newMetadata.currentSchemaId() > schemaId) {
addAndSetCurrentSchema(
newMetadata.schemas().stream()
.filter(schema -> schema.schemaId() > schemaId)
.collect(Collectors.toList()),
newMetadata.currentSchemaId(),
updateBuilder);
+ } else if (newMetadata.currentSchemaId() < schemaId) {
+ // a rollback moved the current schema back; only the pointer
moves
+ updateBuilder.setCurrentSchema(newMetadata.currentSchemaId());
}
// add snapshot
@@ -468,6 +484,21 @@ public class IcebergRestMetadataCommitter implements
IcebergMetadataCommitter {
return false;
}
+ // the same numeric id can belong to a rolled-back timeline; extending
from it would
+ // keep the abandoned history in the catalog
+ IcebergSnapshot baseCurrent = baseIcebergMetadata.currentSnapshot();
+ if (baseCurrent != null
+ && currentMetadata.currentSnapshot().snapshotId() ==
baseCurrent.snapshotId()) {
+ String catalogIdentity =
+
currentMetadata.currentSnapshot().summary().get(PAIMON_COMMIT_IDENTITY);
+ String baseIdentity =
baseCurrent.summary().get(PAIMON_COMMIT_IDENTITY);
+ if (catalogIdentity != null
+ && baseIdentity != null
+ && !catalogIdentity.equals(baseIdentity)) {
+ return false;
+ }
+ }
+
// if the iceberg table is existed, check whether the current metadata
of the table is the
// base of the new table metadata, we use current snapshot id to check.
// Note: callers must ensure currentMetadata.currentSnapshot() is
non-null before calling
@@ -603,7 +634,15 @@ public class IcebergRestMetadataCommitter implements
IcebergMetadataCommitter {
snapshot.sequenceNumber(),
snapshot.snapshotId(),
snapshot.parentSnapshotId(),
- snapshot.timestampMs(),
+ // a slow rebuild must not trip
Iceberg's
+ // one-minute update-timestamp
window
+ snapshot.snapshotId()
+ ==
newIcebergMetadata
+
.currentSnapshotId()
+ ? Math.max(
+
snapshot.timestampMs(),
+
System.currentTimeMillis() - 59_000L)
+ : snapshot.timestampMs(),
snapshot.summary(),
snapshot.manifestList(),
remappedSchemaId,
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 0fbf130a73..052f76bda7 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
@@ -415,6 +415,85 @@ public class IcebergRestMetadataCommitterTest {
commit.close();
}
+ @Test
+ public void testCommitAfterSchemaRollback() throws Exception {
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.INT(), DataTypes.INT()}, new
String[] {"k", "v"});
+ FileStoreTable table =
+ createPaimonTable(
+ rowType,
+ Collections.emptyList(),
+ Collections.singletonList("k"),
+ 1,
+ randomFormat(),
+ Collections.emptyMap());
+
+ 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(false, 1));
+ table.createTag("before-evolution", 1);
+
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+ schemaManager.commitChanges(SchemaChange.addColumn("v2",
DataTypes.STRING()));
+ table = table.copy(table.schemaManager().latest().get());
+ write.close();
+ write = table.newWrite(commitUser);
+ commit.close();
+ commit = table.newCommit(commitUser);
+ write.write(GenericRow.of(2, 20, BinaryString.fromString("two")));
+ commit.commit(2, write.prepareCommit(false, 2));
+ write.close();
+ commit.close();
+
+ TableCommitImpl rollbackCommit = table.newCommit(commitUser);
+
rollbackCommit.rollbackToAsLatest(table.tagManager().getOrThrow("before-evolution"));
+ rollbackCommit.close();
+
+ assertThat(getIcebergResult()).containsExactlyInAnyOrder("Record(1,
10, null)");
+ Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb",
"t"));
+ System.out.println(
+ "PROBE catalog currentSchemaId="
+ + ((org.apache.iceberg.BaseTable) icebergTable)
+ .operations()
+ .current()
+ .currentSchemaId()
+ + " schemas="
+ + icebergTable.schemas().keySet()
+ + " snapSchemaId="
+ + icebergTable.currentSnapshot().schemaId());
+ long localLatest = table.snapshotManager().latestSnapshotId();
+ org.apache.paimon.iceberg.metadata.IcebergMetadata localMeta =
+ org.apache.paimon.iceberg.metadata.IcebergMetadata.fromPath(
+ table.fileIO(),
+ new org.apache.paimon.fs.Path(
+ table.location().getParent().getParent(),
+ "iceberg/mydb/t/metadata/v" + localLatest +
".metadata.json"));
+ System.out.println(
+ "PROBE local current="
+ + localMeta.currentSchemaId()
+ + " snapEntry="
+ + localMeta.currentSnapshot().schemaId());
+ System.out.println(
+ "PROBE schemaLatest="
+ + new SchemaManager(table.fileIO(),
table.location()).latest().get().id());
+ for (org.apache.paimon.fs.FileStatus st :
+ table.fileIO()
+ .listStatus(
+ new org.apache.paimon.fs.Path(
+
table.location().getParent().getParent(),
+ "iceberg/mydb/t/metadata"))) {
+ if (st.getPath().getName().endsWith(".metadata.json")) {
+ System.out.println("PROBE json: " + st.getPath().getName());
+ }
+ }
+ assertThat(icebergTable.schema().columns().stream().map(c -> c.name()))
+ .containsExactly("k", "v", "v2");
+ assertThat(icebergTable.currentSnapshot().schemaId()).isEqualTo(1);
+ }
+
@Test
public void testOptionOnlyAlterTableDoesNotCrashIcebergSync() throws
Exception {
// The fix deduplicates schemas in adjustMetadataForRest() and remaps
@@ -751,6 +830,158 @@ public class IcebergRestMetadataCommitterTest {
.containsExactlyInAnyOrder("Record(1, 11)", "Record(2, 20)",
"Record(3, 30)");
}
+ @Test
+ public void testRetryAdvancesLaggingCatalogWithoutRecreate() 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,
+ randomFormat(),
+ Collections.emptyMap());
+
+ 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(false, 1));
+ write.close();
+ commit.close();
+
+ FileStoreTable localOnly =
+ table.copy(
+ Collections.singletonMap(
+ IcebergOptions.METADATA_ICEBERG_STORAGE.key(),
"hadoop-catalog"));
+ TableWriteImpl<?> write2 = localOnly.newWrite(commitUser);
+ TableCommitImpl commit2 = localOnly.newCommit(commitUser);
+ write2.write(GenericRow.of(2, 20));
+ commit2.commit(2, write2.prepareCommit(false, 2));
+ write2.close();
+ commit2.close();
+
+ Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb",
"t"));
+ assertThat(icebergTable.currentSnapshot().snapshotId()).isEqualTo(1);
+ icebergTable.updateProperties().set("user-custom-prop",
"kept").commit();
+ String uuidBefore = ((BaseTable)
icebergTable).operations().current().uuid();
+
+ IcebergCommitCallback callback = new IcebergCommitCallback(table,
commitUser);
+ callback.retry(new org.apache.paimon.manifest.ManifestCommittable(2));
+ callback.close();
+
+ Table reloaded = restCatalog.loadTable(TableIdentifier.of("mydb",
"t"));
+ assertThat(((BaseTable)
reloaded).operations().current().uuid()).isEqualTo(uuidBefore);
+ assertThat(reloaded.properties()).containsEntry("user-custom-prop",
"kept");
+ assertThat(reloaded.currentSnapshot().snapshotId()).isEqualTo(2);
+ }
+
+ @Test
+ public void testAbandonedBaseWithSameIdIsNotExtendedInCatalog() 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,
+ randomFormat(),
+ Collections.emptyMap());
+
+ 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(false, 1));
+ write.write(GenericRow.of(2, 20));
+ commit.commit(2, write.prepareCommit(false, 2));
+ write.close();
+ commit.close();
+
+ table.rollbackTo(1);
+ FileStoreTable localOnly =
+ table.copy(
+ Collections.singletonMap(
+ IcebergOptions.METADATA_ICEBERG_STORAGE.key(),
"hadoop-catalog"));
+ TableWriteImpl<?> write2 = localOnly.newWrite(commitUser);
+ TableCommitImpl commit2 = localOnly.newCommit(commitUser);
+ write2.write(GenericRow.of(3, 30));
+ commit2.commit(3, write2.prepareCommit(false, 3));
+ write2.close();
+ commit2.close();
+
+ TableWriteImpl<?> write3 = table.newWrite(commitUser);
+ TableCommitImpl commit3 = table.newCommit(commitUser);
+ write3.write(GenericRow.of(4, 40));
+ commit3.commit(4, write3.prepareCommit(false, 4));
+ write3.close();
+ commit3.close();
+
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1, 10)", "Record(3, 30)",
"Record(4, 40)");
+
+ org.apache.paimon.iceberg.metadata.IcebergMetadata localMetadata =
+ org.apache.paimon.iceberg.metadata.IcebergMetadata.fromPath(
+ table.fileIO(),
+ new org.apache.paimon.fs.Path(
+
IcebergCommitCallback.catalogTableMetadataPath(table),
+ "v3.metadata.json"));
+ String localIdentity =
+ localMetadata.snapshots().stream()
+ .filter(snap -> snap.snapshotId() == 2)
+ .findFirst()
+ .get()
+ .summary()
+ .get("paimon-commit-identity");
+ Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb",
"t"));
+ org.apache.iceberg.Snapshot catalogSnapshot2 =
icebergTable.snapshot(2);
+ if (catalogSnapshot2 != null) {
+
assertThat(catalogSnapshot2.summary().get("paimon-commit-identity"))
+ .isEqualTo(localIdentity);
+ }
+ }
+
+ @Test
+ public void testIdempotentRetryDoesNotRecreateTable() 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,
+ randomFormat(),
+ Collections.emptyMap());
+
+ 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(false, 1));
+ write.write(GenericRow.of(2, 20));
+ commit.commit(2, write.prepareCommit(false, 2));
+ write.close();
+ commit.close();
+
+ Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb",
"t"));
+ icebergTable.updateProperties().set("user-custom-prop",
"kept").commit();
+ String uuidBefore = ((BaseTable)
icebergTable).operations().current().uuid();
+
+ IcebergCommitCallback callback = new IcebergCommitCallback(table,
commitUser);
+ callback.retry(new org.apache.paimon.manifest.ManifestCommittable(2));
+ callback.close();
+
+ Table reloaded = restCatalog.loadTable(TableIdentifier.of("mydb",
"t"));
+ assertThat(((BaseTable)
reloaded).operations().current().uuid()).isEqualTo(uuidBefore);
+ assertThat(reloaded.properties()).containsEntry("user-custom-prop",
"kept");
+ }
+
@Test
public void testWithIncorrectBase() throws Exception {
RowType rowType =