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 0023c3512f [iceberg] Fix schema drift crash in
IcebergRestMetadataCommitter (#8335)
0023c3512f is described below
commit 0023c3512f29c743d2a554e2f3d92cee8e6426f5
Author: Oleksandr Nitavskyi <[email protected]>
AuthorDate: Wed Jun 24 03:23:28 2026 +0200
[iceberg] Fix schema drift crash in IcebergRestMetadataCommitter (#8335)
Option-only alterTable calls (e.g. changing metadata.iceberg.*
properties) increment Paimon's schema ID without modifying columns,
producing multiple IcebergSchema objects with identical fields but
different IDs.
Root cause: TableMetadata.Builder.addSchema() uses sameSchema() to
deduplicate identical schemas internally, collapsing them to the first
ID. When setCurrentSchema(N) is called with the higher (non-surviving)
ID, it fails:
"Cannot set current schema to unknown schema: N"
---
.../iceberg/IcebergRestMetadataCommitter.java | 223 +++++++++++++++++----
.../iceberg/IcebergRestMetadataCommitterTest.java | 183 +++++++++++++++++
2 files changed, 364 insertions(+), 42 deletions(-)
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 f144ea96de..820fb2ff94 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
@@ -50,8 +50,10 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nullable;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -155,20 +157,33 @@ public class IcebergRestMetadataCommitter implements
IcebergMetadataCommitter {
icebergTable = getTable();
TableMetadata metadata = ((BaseTable)
icebergTable).operations().current();
- boolean withBase = checkBase(metadata, newMetadata,
baseIcebergMetadata);
- if (withBase) {
- LOG.info("create updates with base metadata.");
- updateBuilder = updatesForCorrectBase(metadata,
newMetadata, false);
- } else {
+
+ 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
+ // subsequent commit() failed (e.g. network error, REST
server timeout).
+ // Treat it as a new table — populate schemas, partition
spec, and the
+ // current snapshot from scratch WITHOUT dropping and
recreating the table.
+ // Routing through updatesForIncorrectBase would call
recreateTable(), which
+ // on a repeated commit failure would create an infinite
drop+create loop.
LOG.info(
- "create updates without base metadata.
currentSnapshotId for base metadata: {}, for new metadata:{}",
- metadata.currentSnapshot() != null
- ? metadata.currentSnapshot().snapshotId()
- : "No snapshot",
- newMetadata.currentSnapshot() != null
- ?
newMetadata.currentSnapshot().snapshotId()
- : "No snapshot");
- updateBuilder = updatesForIncorrectBase(newMetadata);
+ "Iceberg table {} exists but has no snapshots,
treating as new table.",
+ icebergTableIdentifier);
+ updateBuilder = updatesForCorrectBase(metadata,
newMetadata, true);
+ } else {
+ boolean withBase = checkBase(metadata, newMetadata,
baseIcebergMetadata);
+ if (withBase) {
+ LOG.info("create updates with base metadata.");
+ updateBuilder = updatesForCorrectBase(metadata,
newMetadata, false);
+ } else {
+ LOG.info(
+ "create updates without base metadata.
currentSnapshotId for base metadata: {}, for new metadata:{}",
+ metadata.currentSnapshot().snapshotId(),
+ newMetadata.currentSnapshot() != null
+ ?
newMetadata.currentSnapshot().snapshotId()
+ : "No snapshot");
+ updateBuilder = updatesForIncorrectBase(newMetadata);
+ }
}
}
} catch (Exception e) {
@@ -243,6 +258,7 @@ public class IcebergRestMetadataCommitter implements
IcebergMetadataCommitter {
removeSnapshots(snapshotIdsToRemove, updateBuilder);
}
+ updateProperties(updateBuilder);
return updateBuilder;
}
@@ -398,16 +414,27 @@ public class IcebergRestMetadataCommitter implements
IcebergMetadataCommitter {
update.addSchema(schema);
}
update.setCurrentSchema(currentSchemaId);
+ }
- // update properties
- Map<String, String> properties = new HashMap<>();
- properties.put(
- METADATA_PREVIOUS_VERSIONS_MAX,
- String.valueOf(icebergOptions.previousVersionsMax()));
- properties.put(
- METADATA_DELETE_AFTER_COMMIT_ENABLED,
- String.valueOf(icebergOptions.deleteAfterCommitEnabled()));
- update.setProperties(properties);
+ // Update Iceberg REST table properties from current IcebergOptions, but
only
+ // if the values differ from what the REST catalog already has. This avoids
+ // emitting a redundant SetProperties update on every commit.
+ private void updateProperties(TableMetadata.Builder update) {
+ String desiredMax =
String.valueOf(icebergOptions.previousVersionsMax());
+ String desiredDeleteAfter =
String.valueOf(icebergOptions.deleteAfterCommitEnabled());
+
+ Map<String, String> current = icebergTable.properties();
+ boolean changed =
+ !desiredMax.equals(current.get(METADATA_PREVIOUS_VERSIONS_MAX))
+ || !desiredDeleteAfter.equals(
+
current.get(METADATA_DELETE_AFTER_COMMIT_ENABLED));
+
+ if (changed) {
+ Map<String, String> properties = new HashMap<>();
+ properties.put(METADATA_PREVIOUS_VERSIONS_MAX, desiredMax);
+ properties.put(METADATA_DELETE_AFTER_COMMIT_ENABLED,
desiredDeleteAfter);
+ update.setProperties(properties);
+ }
}
//
-------------------------------------------------------------------------------------
@@ -432,37 +459,149 @@ public class IcebergRestMetadataCommitter implements
IcebergMetadataCommitter {
}
// 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
+ // base of the new table metadata, we use current snapshot id to check.
+ // Note: callers must ensure currentMetadata.currentSnapshot() is
non-null before calling
+ // this method (guarded in commitMetadataImpl).
return currentMetadata.currentSnapshot().snapshotId()
== newMetadata.currentSnapshot().snapshotId() - 1;
}
private IcebergMetadata adjustMetadataForRest(IcebergMetadata
newIcebergMetadata) {
- // why need this:
- // Since we will use an empty schema to create iceberg table in rest
catalog and id-0 will
- // be occupied by the empty schema, there will be 1-unit offset
between the schema-id in
- // metadata stored in rest catalog and the schema-id in paimon.
-
- List<IcebergSchema> schemas =
+ // --- Why we shift schema IDs by +1 ---
+ // When we create an Iceberg table via the REST catalog, we register
it with
+ // an empty schema that occupies schema ID 0. Paimon's own schema IDs
start
+ // at 0 as well, so we shift every Paimon schema ID by +1 to avoid
colliding
+ // with that placeholder.
+ //
+ // --- Why we deduplicate schemas ---
+ // Option-only alterTable calls in Paimon (e.g. changing table
properties
+ // like metadata.iceberg.* settings) increment Paimon's internal schema
+ // version without modifying the column definitions. This means Paimon
can
+ // accumulate multiple schema versions (e.g. IDs 0, 1, 2, 3) that all
have
+ // identical field lists.
+ //
+ // When these schemas are forwarded to Iceberg,
addAndSetCurrentSchema()
+ // calls TableMetadata.Builder.addSchema() for each one. Iceberg
internally
+ // deduplicates identical schemas via its sameSchema() check — it
keeps only
+ // the first occurrence and silently drops duplicates. So if schemas
1, 2, 3
+ // all have the same fields, Iceberg keeps only schema 1.
+ //
+ // By deduplicating here (using IcebergDataField.equals() which
compares id,
+ // name, required, type, and doc), we keep only unique schemas and
build a
+ // remap table so that currentSchemaId and snapshot schemaId
references point
+ // to the surviving schema's ID. The downstream metadata is then
internally
+ // consistent with what Iceberg catalog will actually store.
+
+ // Step 1 — Shift and convert: produce Iceberg Schema objects so we
can call
+ // sameSchema() in the dedup step.
+ List<IcebergSchema> shiftedIcebergSchemas =
newIcebergMetadata.schemas().stream()
- .map(schema -> new IcebergSchema(schema.schemaId() +
1, schema.fields()))
+ .map(s -> new IcebergSchema(s.schemaId() + 1,
s.fields()))
.collect(Collectors.toList());
- int currentSchemaId = newIcebergMetadata.currentSchemaId() + 1;
+ int shiftedCurrentSchemaId = newIcebergMetadata.currentSchemaId() + 1;
+ // Build a temporary IcebergMetadata with shifted IDs to obtain
Iceberg Schema objects.
+ IcebergMetadata shiftedForConversion =
+ new IcebergMetadata(
+ newIcebergMetadata.formatVersion(),
+ newIcebergMetadata.tableUuid(),
+ newIcebergMetadata.location(),
+ newIcebergMetadata.currentSnapshotId(),
+ newIcebergMetadata.lastColumnId(),
+ shiftedIcebergSchemas,
+ shiftedCurrentSchemaId,
+ newIcebergMetadata.partitionSpecs(),
+ newIcebergMetadata.lastPartitionId(),
+ newIcebergMetadata.snapshots().stream()
+ .map(
+ s ->
+ new IcebergSnapshot(
+ s.sequenceNumber(),
+ s.snapshotId(),
+ s.parentSnapshotId(),
+ s.timestampMs(),
+ s.summary(),
+ s.manifestList(),
+ s.schemaId() + 1,
+ s.firstRowId(),
+ s.addedRows()))
+ .collect(Collectors.toList()),
+ newIcebergMetadata.currentSnapshotId(),
+ newIcebergMetadata.refs());
+ TableMetadata shiftedTableMetadata =
+ TableMetadataParser.fromJson(shiftedForConversion.toJson());
+ Map<Integer, IcebergSchema> shiftedById =
+ shiftedIcebergSchemas.stream()
+ .collect(Collectors.toMap(IcebergSchema::schemaId, s
-> s));
+
+ // Step 2 — Deduplicate using sameSchema(): keeps first occurrence
(insertion order
+ // preserved by LinkedHashMap), remaps duplicates to the surviving
schema ID.
+ // Maps each shifted schema ID → the surviving (deduped) schema ID
(before renumbering).
+ LinkedHashMap<Integer, Schema> survivingById = new LinkedHashMap<>();
+ Map<Integer, Integer> schemaIdRemap = new HashMap<>();
+
+ for (Schema schema : shiftedTableMetadata.schemas()) {
+ int shiftedId = schema.schemaId();
+ Integer survivingId =
+ survivingById.keySet().stream()
+ .filter(sid ->
survivingById.get(sid).sameSchema(schema))
+ .findFirst()
+ .orElse(null);
+ if (survivingId != null) {
+ // Duplicate: remap this ID to the first schema with the same
structure
+ schemaIdRemap.put(shiftedId, survivingId);
+ } else {
+ // First occurrence: keep it
+ survivingById.put(shiftedId, schema);
+ schemaIdRemap.put(shiftedId, shiftedId);
+ }
+ }
+
+ // Step 2 — Renumber: after dedup, there may be gaps in the schema ID
sequence
+ // (e.g. [1, 2, 4] when schema 3 was deduped into 2). Iceberg's
addSchema() assigns
+ // IDs sequentially as max(existing) + 1, so it would produce [1, 2,
3] — not [1, 2, 4].
+ // Calling setCurrentSchema(4) would then fail with "unknown schema:
4".
+ // We renumber the surviving schemas to 1, 2, 3, ... and update the
remap table so
+ // that currentSchemaId and snapshot schemaId references stay
consistent.
+ Map<Integer, Integer> renumberMap = new HashMap<>(); // old deduped ID
→ new sequential ID
+ int nextId = 1;
+ List<IcebergSchema> schemas = new ArrayList<>();
+ for (int survivingId : survivingById.keySet()) {
+ renumberMap.put(survivingId, nextId);
+ schemas.add(new IcebergSchema(nextId,
shiftedById.get(survivingId).fields()));
+ nextId++;
+ }
+ // Apply renumbering on top of the dedup remap
+ schemaIdRemap.replaceAll((k, dedupedId) -> renumberMap.get(dedupedId));
+
+ // 3. Remap currentSchemaId through dedup + renumber
+ int currentSchemaId =
+ schemaIdRemap.getOrDefault(
+ newIcebergMetadata.currentSchemaId() + 1,
+ newIcebergMetadata.currentSchemaId() + 1);
+
+ // Remap snapshot schema references so they point to surviving schema
IDs
List<IcebergSnapshot> snapshots =
newIcebergMetadata.snapshots().stream()
.map(
- snapshot ->
- new IcebergSnapshot(
- snapshot.sequenceNumber(),
- snapshot.snapshotId(),
- snapshot.parentSnapshotId(),
- snapshot.timestampMs(),
- snapshot.summary(),
- snapshot.manifestList(),
- snapshot.schemaId() + 1,
- snapshot.firstRowId(),
- snapshot.addedRows()))
+ snapshot -> {
+ int shiftedSnapshotSchemaId =
snapshot.schemaId() + 1;
+ int remappedSchemaId =
+ schemaIdRemap.getOrDefault(
+ shiftedSnapshotSchemaId,
+ shiftedSnapshotSchemaId);
+ return new IcebergSnapshot(
+ snapshot.sequenceNumber(),
+ snapshot.snapshotId(),
+ snapshot.parentSnapshotId(),
+ snapshot.timestampMs(),
+ snapshot.summary(),
+ snapshot.manifestList(),
+ remappedSchemaId,
+ snapshot.firstRowId(),
+ snapshot.addedRows());
+ })
.collect(Collectors.toList());
+
return new IcebergMetadata(
newIcebergMetadata.formatVersion(),
newIcebergMetadata.tableUuid(),
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 de39ea6f6d..a3ae135686 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
@@ -414,6 +414,189 @@ public class IcebergRestMetadataCommitterTest {
commit.close();
}
+ @Test
+ public void testOptionOnlyAlterTableDoesNotCrashIcebergSync() throws
Exception {
+ // The fix deduplicates schemas in adjustMetadataForRest() and remaps
+ // currentSchemaId + snapshot schemaId references to the surviving ID.
+ 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);
+
+ // Initial write — establishes schema-0 in Paimon, schema-1 in Iceberg
+ write.write(GenericRow.of(1, 10));
+ write.write(GenericRow.of(2, 20));
+ commit.commit(1, write.prepareCommit(false, 1));
+ assertThat(getIcebergResult()).containsExactlyInAnyOrder("Record(1,
10)", "Record(2, 20)");
+
+ // Perform 3 option-only schema changes — each increments Paimon
schema ID
+ // but does NOT change columns, creating the dedup scenario
+ SchemaManager schemaManager = new SchemaManager(table.fileIO(),
table.location());
+
schemaManager.commitChanges(SchemaChange.setOption("my.custom.option.1",
"value1"));
+
schemaManager.commitChanges(SchemaChange.setOption("my.custom.option.2",
"value2"));
+
schemaManager.commitChanges(SchemaChange.setOption("my.custom.option.3",
"value3"));
+ table = table.copy(table.schemaManager().latest().get());
+ write.close();
+ write = table.newWrite(commitUser);
+ commit.close();
+ commit = table.newCommit(commitUser);
+
+ // Write more data — this triggers IcebergRestMetadataCommitter with
+ // multiple schemas that have identical fields but different IDs.
+ // Without the fix, this crashes with "Cannot set current schema to
+ // unknown schema: N".
+ write.write(GenericRow.of(1, 11));
+ write.write(GenericRow.of(3, 30));
+ write.compact(BinaryRow.EMPTY_ROW, 0, true);
+ commit.commit(2, write.prepareCommit(true, 2));
+
+ // Verify data is readable through Iceberg
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1, 11)", "Record(2, 20)",
"Record(3, 30)");
+
+ // Verify schema dedup happened: Iceberg should have fewer schemas than
+ // Paimon (Paimon has 4 schemas: original + 3 option changes; Iceberg
+ // should have just 1 unique schema after dedup, plus the empty
placeholder)
+ Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb",
"t"));
+ int icebergSchemaCount = icebergTable.schemas().size();
+ int paimonSchemaCount = table.schemaManager().listAllIds().size();
+ assertThat(icebergSchemaCount).isLessThan(paimonSchemaCount);
+
+ // Perform another write + commit to verify no drift on subsequent
commits
+ // (the deduped currentSchemaId should match what Iceberg stored, so we
+ // should NOT re-enter the schema update path unnecessarily)
+ write.write(GenericRow.of(2, 21));
+ write.compact(BinaryRow.EMPTY_ROW, 0, true);
+ commit.commit(3, write.prepareCommit(true, 3));
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1, 11)", "Record(2, 21)",
"Record(3, 30)");
+
+ write.close();
+ commit.close();
+ }
+
+ @Test
+ public void testSchemaEvolutionWithInterleavedOptionAlter() throws
Exception {
+ // Verifies correct behaviour across a full schema-evolution cycle:
+ //
+ // 1. Initial schema: [k, v1, v2]
+ // 2. Drop column v2: [k, v1] — genuine schema change
+ // 3. Option-only alter on [k, v1] — same fields; must be
deduped with step 2
+ // 4. Re-add column v2 (BIGINT): [k, v1, v2_new] — new field ID;
must NOT be
+ // deduped with step 1 even though the column name matches
+ //
+ // Without the fix, step 3 crashes with "Cannot set current schema to
unknown schema: N"
+ // and step 4 would expose a renumbering bug where Iceberg assigns
sequential IDs
+ // (1, 2, 3) while setCurrentSchema references the gap-containing ID
(4).
+ RowType rowType =
+ RowType.of(
+ new DataType[] {DataTypes.INT(), DataTypes.INT(),
DataTypes.STRING()},
+ new String[] {"k", "v1", "v2"});
+ FileStoreTable table =
+ createPaimonTable(
+ rowType,
+ Collections.emptyList(),
+ Collections.singletonList("k"),
+ 1,
+ randomFormat(),
+ Collections.emptyMap());
+ String commitUser = UUID.randomUUID().toString();
+ EvolveContext ctx = new EvolveContext(table, commitUser);
+
+ // Step 1: baseline
+ ctx.writeAndCommit(
+ GenericRow.of(1, 10, BinaryString.fromString("a")),
+ GenericRow.of(2, 20, BinaryString.fromString("b")));
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1, 10, a)", "Record(2, 20,
b)");
+
+ // Step 2: drop v2 → [k, v1]
+ ctx.evolve(SchemaChange.dropColumn("v2"));
+ ctx.writeAndCommit(GenericRow.of(1, 11), GenericRow.of(3, 30));
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1, 11)", "Record(2, 20)",
"Record(3, 30)");
+
+ // Step 3: option-only alter — same fields [k, v1], increments Paimon
schema ID
+ ctx.evolve(SchemaChange.setOption("my.test.option", "trigger-dedup"));
+ ctx.writeAndCommit(GenericRow.of(2, 21));
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder("Record(1, 11)", "Record(2, 21)",
"Record(3, 30)");
+
+ // Step 4: re-add v2 as BIGINT — new field ID, distinct from original
v2 (STRING)
+ ctx.evolve(SchemaChange.addColumn("v2", DataTypes.BIGINT()));
+ ctx.writeAndCommit(GenericRow.of(1, 11, 100L), GenericRow.of(4, 40,
400L));
+ assertThat(getIcebergResult())
+ .containsExactlyInAnyOrder(
+ "Record(1, 11, 100)",
+ "Record(2, 21, null)",
+ "Record(3, 30, null)",
+ "Record(4, 40, 400)");
+
+ // Final Iceberg schema must reflect [k, v1, v2_new(BIGINT)], not the
old v2(STRING)
+ Table icebergTable = restCatalog.loadTable(TableIdentifier.of("mydb",
"t"));
+ assertThat(icebergTable.schema().columns()).hasSize(3);
+
assertThat(icebergTable.schema().findField("v2").type().toString()).isEqualTo("long");
+
+ // Paimon has 4 schema versions (0-3); step 3 is a duplicate of step 2
(same fields).
+ // Iceberg should have exactly 4 schemas: the empty placeholder (id=0)
plus 3 unique
+ // field-sets. Without dedup it would have 5 (empty +
one-per-Paimon-schema).
+ int paimonSchemaCount = ctx.table.schemaManager().listAllIds().size();
+ assertThat(paimonSchemaCount).isEqualTo(4);
+ assertThat(icebergTable.schemas().size()).isEqualTo(4); // 5 without
dedup
+
+ ctx.close();
+ }
+
+ /** Helper context that keeps write/commit in sync with table schema
evolution. */
+ private class EvolveContext implements AutoCloseable {
+ FileStoreTable table;
+ private final String commitUser;
+ private TableWriteImpl<?> write;
+ private TableCommitImpl commit;
+ private int seq = 0;
+
+ EvolveContext(FileStoreTable table, String commitUser) throws
Exception {
+ this.table = table;
+ this.commitUser = commitUser;
+ this.write = table.newWrite(commitUser);
+ this.commit = table.newCommit(commitUser);
+ }
+
+ void evolve(SchemaChange change) throws Exception {
+ new SchemaManager(table.fileIO(),
table.location()).commitChanges(change);
+ table = table.copy(table.schemaManager().latest().get());
+ write.close();
+ write = table.newWrite(commitUser);
+ commit.close();
+ commit = table.newCommit(commitUser);
+ }
+
+ void writeAndCommit(GenericRow... rows) throws Exception {
+ for (GenericRow row : rows) {
+ write.write(row);
+ }
+ write.compact(BinaryRow.EMPTY_ROW, 0, true);
+ commit.commit(++seq, write.prepareCommit(true, seq));
+ }
+
+ @Override
+ public void close() throws Exception {
+ write.close();
+ commit.close();
+ }
+ }
+
@Test
public void testSchemaChangeBeforeSync() throws Exception {
RowType rowType =