This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 259c332b282 [fix](fe) Reject unsafe Iceberg column drops (#66627)
259c332b282 is described below
commit 259c332b28228e8f53fbf54bb6aca05ca7557da5
Author: Gabriel <[email protected]>
AuthorDate: Thu Aug 13 11:50:46 2026 +0800
[fix](fe) Reject unsafe Iceberg column drops (#66627)
### What problem does this PR solve?
Issue Number: None
Related PR: None
Problem Summary: Dropping an Iceberg column can leave historical
partition specs referencing a field ID that no longer exists in the
current schema, which makes historical metadata unreadable. The same
failure occurs when dropping a struct ancestor of a referenced nested
field. Resolve top-level and nested drop targets before committing the
schema update, then reject the drop when any historical partition spec
references the target field or a field in its subtree.
### Release note
Iceberg column drops now fail explicitly when the column, or a nested
field beneath it, is referenced by a historical partition spec.
### Check List (For Author)
- Test
- [ ] Regression test
- [x] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason
- Behavior changed:
- [ ] No.
- [x] Yes. Invalid Iceberg column drops are rejected before corrupting
historical partition metadata.
- Does this need documentation?
- [x] No.
- [ ] Yes.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../doris/connector/iceberg/IcebergCatalogOps.java | 4 +-
.../iceberg/IcebergNestedColumnEvolution.java | 248 ++++++++++++-
...BackedIcebergCatalogOpsColumnEvolutionTest.java | 23 ++
.../iceberg/IcebergNestedColumnEvolutionTest.java | 394 +++++++++++++++++++++
4 files changed, 660 insertions(+), 9 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
index 80fa39ae8b8..4a7d516702f 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
@@ -458,9 +458,7 @@ public interface IcebergCatalogOps {
@Override
public void dropColumn(String dbName, String tableName, String
columnName) {
- UpdateSchema updateSchema = loadTable(dbName,
tableName).updateSchema();
- updateSchema.deleteColumn(columnName);
- updateSchema.commit();
+ IcebergNestedColumnEvolution.dropTopLevelColumn(loadTable(dbName,
tableName), columnName);
}
@Override
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java
index 06a850e8a94..b4ca1c02c7d 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolution.java
@@ -21,15 +21,25 @@ import
org.apache.doris.connector.spi.DorisConnectorException;
import org.apache.doris.connector.spi.ddl.ConnectorColumnPath;
import org.apache.doris.connector.spi.ddl.ConnectorColumnPosition;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.HasTableOperations;
import org.apache.iceberg.Schema;
import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableOperations;
import org.apache.iceberg.UpdateSchema;
+import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.LocationProvider;
+import org.apache.iceberg.transforms.Transforms;
import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.TypeUtil;
import org.apache.iceberg.types.Types;
import org.apache.iceberg.types.Types.NestedField;
import java.util.ArrayList;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
@@ -47,8 +57,8 @@ import java.util.TreeSet;
* {@link IcebergColumnChange}), so this class only ever touches iceberg +
neutral SPI types — never a Doris type.
* Every failure is raised as a {@link DorisConnectorException} (the caller
maps it to a {@code DdlException}).</p>
*
- * <p><b>No partial commit:</b> every {@code UpdateSchema.commit()} is the
final statement of each entry point, so
- * any validation/resolution throw aborts the whole change before anything is
committed (legacy parity).</p>
+ * <p><b>No partial commit:</b> every entry point validates before its single
metadata commit, so any
+ * validation/resolution throw aborts the whole change before anything is
committed (legacy parity).</p>
*
* <p>The TOP-LEVEL (flat) column ops stay on {@link IcebergCatalogOps}, but
they share this class's name
* resolution and validators — iceberg matches field names case-SENSITIVELY
while Doris column names are
@@ -57,6 +67,8 @@ import java.util.TreeSet;
*/
public final class IcebergNestedColumnEvolution {
+ private static final String REST_DROP_FENCE_PREFIX =
"_doris_schema_drop_fence_";
+
private IcebergNestedColumnEvolution() {
}
@@ -87,10 +99,234 @@ public final class IcebergNestedColumnEvolution {
/** Drops the nested field at {@code path}; its parent must resolve to a
struct that contains the leaf. */
public static void dropColumn(Table table, ConnectorColumnPath path) {
- ResolvedColumnPath resolvedPath =
validateNestedStructFieldPath(table.schema(), path, "drop");
- UpdateSchema updateSchema = table.updateSchema();
- updateSchema.deleteColumn(resolvedPath.getFullPath());
- updateSchema.commit();
+ dropColumnSafely(table, path, true);
+ }
+
+ static void dropTopLevelColumn(Table table, String columnName) {
+ dropColumnSafely(table, ConnectorColumnPath.of(columnName), false);
+ }
+
+ private static void dropColumnSafely(
+ Table table, ConnectorColumnPath path, boolean nested) {
+ TableOperations operations = ((HasTableOperations) table).operations();
+ TableMetadata loaded = operations.current();
+ if (loaded == null) {
+ throw new DorisConnectorException("Cannot drop column from an
unloaded Iceberg table: " + table.name());
+ }
+ ResolvedColumnPath loadedPath = resolveDropPath(loaded.schema(), path,
nested);
+ Set<Integer> loadedSubtreeIds = fieldSubtreeIds(loadedPath);
+
+ TableMetadata base = operations.refresh();
+ ResolvedColumnPath resolvedPath = validatePinnedDropIdentity(
+ table.name(), path, nested, loaded, loadedSubtreeIds, base);
+ validateNotUsedByRetainedPartitionSpec(base, resolvedPath);
+
+ if (isRestTableOperations(operations)) {
+ commitRestDropWithSpecFence(operations, table.name(), base,
resolvedPath);
+ return;
+ }
+
+ // Use Iceberg's standard commit so column-scoped writer properties
and name mappings are transformed.
+ // Direct catalogs atomically reject any metadata change after the
refresh through their metadata CAS.
+ new BaseTable(operations, table.name()).updateSchema()
+ .deleteColumn(resolvedPath.getFullPath()).commit();
+ }
+
+ private static void commitRestDropWithSpecFence(
+ TableOperations operations, String tableName, TableMetadata base,
ResolvedColumnPath resolvedPath) {
+ if (base.formatVersion() < 2) {
+ throw new DorisConnectorException(
+ "Cannot safely drop a column from a format-v1 Iceberg REST
table: " + tableName);
+ }
+
+ Set<Integer> droppedFieldIds = fieldSubtreeIds(resolvedPath);
+ String fenceSource = findRestFenceSource(base, droppedFieldIds);
+ if (fenceSource == null) {
+ throw new DorisConnectorException(
+ "Cannot safely fence a column drop in Iceberg REST table:
" + tableName);
+ }
+ String fenceName = newRestFenceName(base);
+
+ // Validation proved that no retained spec references the drop target,
so any concurrent new reference
+ // must allocate a partition field ID. Adding a distinct void field
makes REST assert that ID counter.
+ CapturingTableOperations schemaCapture = new
CapturingTableOperations(operations, base);
+ new BaseTable(schemaCapture, tableName).updateSchema()
+ .deleteColumn(resolvedPath.getFullPath()).commit();
+
+ CapturingTableOperations fenceCapture = new
CapturingTableOperations(operations, base);
+ new BaseTable(fenceCapture, tableName).updateSpec()
+ .addField(fenceName, Expressions.transform(fenceSource,
Transforms.alwaysNull()))
+ .addNonDefaultSpec().commit();
+
+ TableMetadata.Builder combined = TableMetadata.buildFrom(base);
+ schemaCapture.committed().changes().forEach(update ->
update.applyTo(combined));
+ fenceCapture.committed().changes().forEach(update ->
update.applyTo(combined));
+ // The durable fence must stay in metadata: removing its
client-predicted spec ID could delete a
+ // concurrently added spec after REST renumbers the fence during
server-side rebase.
+ operations.commit(base, combined.build());
+ }
+
+ private static String findRestFenceSource(TableMetadata metadata,
Set<Integer> droppedFieldIds) {
+ LinkedHashSet<Integer> candidates = new LinkedHashSet<>();
+ metadata.specs().forEach(spec -> spec.fields().forEach(field ->
candidates.add(field.sourceId())));
+ candidates.addAll(metadata.schema().identifierFieldIds());
+ collectStructPrimitiveFieldIds(metadata.schema().asStruct(),
droppedFieldIds, candidates);
+
+ return candidates.stream()
+ .filter(id -> !droppedFieldIds.contains(id))
+ .filter(id -> metadata.schema().findField(id) != null)
+ .filter(id -> metadata.spec().fields().stream()
+ .noneMatch(field -> field.sourceId() == id &&
field.transform().isVoid()))
+ .map(metadata.schema()::findColumnName)
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
+ }
+
+ private static void collectStructPrimitiveFieldIds(
+ Types.StructType struct, Set<Integer> droppedFieldIds,
Set<Integer> candidates) {
+ for (NestedField field : struct.fields()) {
+ if (droppedFieldIds.contains(field.fieldId())) {
+ continue;
+ }
+ if (field.type().isPrimitiveType()) {
+ candidates.add(field.fieldId());
+ } else if (field.type().isStructType()) {
+ collectStructPrimitiveFieldIds(field.type().asStructType(),
droppedFieldIds, candidates);
+ }
+ }
+ }
+
+ private static String newRestFenceName(TableMetadata metadata) {
+ int suffix = metadata.lastAssignedPartitionId() + 1;
+ String candidate = REST_DROP_FENCE_PREFIX + suffix;
+ while (hasFieldOrPartitionName(metadata, candidate)) {
+ candidate = REST_DROP_FENCE_PREFIX + ++suffix;
+ }
+ return candidate;
+ }
+
+ private static boolean hasFieldOrPartitionName(TableMetadata metadata,
String candidate) {
+ boolean fieldNameExists =
TypeUtil.indexById(metadata.schema().asStruct()).values().stream()
+ .anyMatch(field -> field.name().equalsIgnoreCase(candidate));
+ return fieldNameExists || metadata.specs().stream().flatMap(spec ->
spec.fields().stream())
+ .anyMatch(field -> field.name().equalsIgnoreCase(candidate));
+ }
+
+ private static ResolvedColumnPath validatePinnedDropIdentity(
+ String tableName, ConnectorColumnPath path, boolean nested,
TableMetadata loaded,
+ Set<Integer> loadedSubtreeIds, TableMetadata refreshed) {
+ if (!Objects.equals(loaded.uuid(), refreshed.uuid())
+ || (loaded.uuid() == null && loaded != refreshed)) {
+ // UUID-less legacy metadata has no generation identity, so only
the exact refreshed object can be
+ // trusted; accepting a different object could retarget the DROP
to a recreated table.
+ throw concurrentDropTargetChange(tableName, path);
+ }
+ try {
+ ResolvedColumnPath refreshedPath =
resolveDropPath(refreshed.schema(), path, nested);
+ // A parent DROP pins every descendant ID as well as the parent
ID, preventing a same-path subtree
+ // replacement from turning an old request into deletion of a
newly created object.
+ if (!loadedSubtreeIds.equals(fieldSubtreeIds(refreshedPath))) {
+ throw concurrentDropTargetChange(tableName, path);
+ }
+ return refreshedPath;
+ } catch (DorisConnectorException e) {
+ throw concurrentDropTargetChange(tableName, path);
+ }
+ }
+
+ private static ResolvedColumnPath resolveDropPath(
+ Schema schema, ConnectorColumnPath path, boolean nested) {
+ return nested
+ ? validateNestedStructFieldPath(schema, path, "drop")
+ : resolveColumnPath(schema, path, "drop");
+ }
+
+ private static Set<Integer> fieldSubtreeIds(ResolvedColumnPath path) {
+ return new
TreeSet<>(TypeUtil.indexById(Types.StructType.of(path.getField())).keySet());
+ }
+
+ private static DorisConnectorException concurrentDropTargetChange(
+ String tableName, ConnectorColumnPath path) {
+ return new DorisConnectorException("Iceberg table or drop target
changed concurrently: "
+ + tableName + "." + path.getFullPath());
+ }
+
+ private static boolean isRestTableOperations(TableOperations operations) {
+ return
"RESTTableOperations".equalsIgnoreCase(operations.getClass().getSimpleName());
+ }
+
+ private static void validateNotUsedByRetainedPartitionSpec(
+ TableMetadata metadata, ResolvedColumnPath columnPath) {
+ Set<Integer> droppedFieldIds = TypeUtil.indexById(
+ Types.StructType.of(columnPath.getField())).keySet();
+ // Every retained spec resolves partition types by source field ID
against the current schema. Checking
+ // the current spec too establishes that a later REST fence can detect
any newly introduced reference.
+ boolean usedByRetainedSpec = metadata.specs().stream()
+ .anyMatch(spec -> spec.fields().stream().anyMatch(field ->
+ droppedFieldIds.contains(field.sourceId())));
+ if (usedByRetainedSpec) {
+ throw new DorisConnectorException(
+ "Cannot drop column which is used by an old partition spec
or current retained partition spec: "
+ + columnPath.getFullPath());
+ }
+ }
+
+ private static final class CapturingTableOperations implements
TableOperations {
+ private final TableOperations delegate;
+ private TableMetadata current;
+ private TableMetadata committed;
+
+ private CapturingTableOperations(TableOperations delegate,
TableMetadata base) {
+ this.delegate = delegate;
+ this.current = base;
+ }
+
+ private TableMetadata committed() {
+ if (committed == null) {
+ throw new IllegalStateException("Iceberg metadata update did
not commit to the capture");
+ }
+ return committed;
+ }
+
+ @Override
+ public TableMetadata current() {
+ return current;
+ }
+
+ @Override
+ public TableMetadata refresh() {
+ return current;
+ }
+
+ @Override
+ public void commit(TableMetadata base, TableMetadata metadata) {
+ if (base != current) {
+ throw new IllegalStateException("Iceberg metadata capture
committed from an unexpected base");
+ }
+ current = metadata;
+ committed = metadata;
+ }
+
+ @Override
+ public FileIO io() {
+ return delegate.io();
+ }
+
+ @Override
+ public EncryptionManager encryption() {
+ return delegate.encryption();
+ }
+
+ @Override
+ public String metadataFileLocation(String fileName) {
+ return delegate.metadataFileLocation(fileName);
+ }
+
+ @Override
+ public LocationProvider locationProvider() {
+ return delegate.locationProvider();
+ }
}
/**
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
index b80f0f6df38..c0b9089b6fd 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/CatalogBackedIcebergCatalogOpsColumnEvolutionTest.java
@@ -23,8 +23,11 @@ import org.apache.doris.connector.spi.ConnectorType;
import org.apache.doris.connector.spi.DorisConnectorException;
import org.apache.doris.connector.spi.ddl.ConnectorColumnPosition;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
import org.apache.iceberg.inmemory.InMemoryCatalog;
import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.Types;
@@ -124,6 +127,26 @@ public class
CatalogBackedIcebergCatalogOpsColumnEvolutionTest {
Assertions.assertNull(reload().findField("val"));
}
+ @Test
+ public void testDropColumnUsedByHistoricalPartitionSpecFailsLoud() {
+ Table table = ops.loadTable("db1", "t1");
+ table.updateSpec().addField("val").commit();
+ String partitionName = table.spec().fields().get(0).name();
+ DataFile dataFile = DataFiles.builder(table.spec())
+ .withPath("file:/warehouse/t1/data.parquet")
+ .withFileSizeInBytes(1)
+ .withRecordCount(1)
+ .withPartitionPath(partitionName + "=1")
+ .build();
+ table.newAppend().appendFile(dataFile).commit();
+ table.updateSpec().removeField(partitionName).commit();
+
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> ops.dropColumn("db1", "t1", "val"));
+ Assertions.assertTrue(ex.getMessage().contains("used by an old
partition spec"), ex.getMessage());
+ Assertions.assertNotNull(reload().findField("val"));
+ }
+
@Test
public void testRenameColumn() {
ops.renameColumn("db1", "t1", "name", "full_name");
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolutionTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolutionTest.java
index 154c85f286b..0f74d82f735 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolutionTest.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergNestedColumnEvolutionTest.java
@@ -23,9 +23,26 @@ import
org.apache.doris.connector.spi.DorisConnectorException;
import org.apache.doris.connector.spi.ddl.ConnectorColumnPath;
import org.apache.doris.connector.spi.ddl.ConnectorColumnPosition;
+import org.apache.iceberg.BaseTable;
+import org.apache.iceberg.DataFile;
+import org.apache.iceberg.DataFiles;
+import org.apache.iceberg.HasTableOperations;
+import org.apache.iceberg.MetadataTableType;
+import org.apache.iceberg.MetadataTableUtils;
+import org.apache.iceberg.MetadataUpdate;
import org.apache.iceberg.PartitionSpec;
import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableOperations;
+import org.apache.iceberg.TableProperties;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.UpdateRequirements;
+import org.apache.iceberg.encryption.EncryptionManager;
+import org.apache.iceberg.exceptions.CommitFailedException;
import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.LocationProvider;
import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.Types;
import org.junit.jupiter.api.AfterEach;
@@ -36,6 +53,8 @@ import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -79,6 +98,11 @@ public class IcebergNestedColumnEvolutionTest {
IcebergSchemaBuilder.buildTableProperties(Collections.emptyMap()));
}
+ private void createTable(String table, Schema schema, PartitionSpec spec,
Map<String, String> properties) {
+ ops.createTable("db1", table, schema, spec, null,
+ IcebergSchemaBuilder.buildTableProperties(properties));
+ }
+
private Schema reload(String table) {
return ops.loadTable("db1", table).schema();
}
@@ -267,6 +291,376 @@ public class IcebergNestedColumnEvolutionTest {
Assertions.assertNotNull(s.field("x"));
}
+ @Test
+ public void testDropNestedFieldUsedByHistoricalPartitionSpecFailsLoud() {
+ createTable("d_old_spec", flatNestedSchema());
+ Table table = ops.loadTable("db1", "d_old_spec");
+ table.updateSpec().addField("s.a").commit();
+ String partitionName = table.spec().fields().get(0).name();
+ DataFile dataFile = DataFiles.builder(table.spec())
+ .withPath("file:/warehouse/d_old_spec/data.parquet")
+ .withFileSizeInBytes(1)
+ .withRecordCount(1)
+ .withPartitionPath(partitionName + "=1")
+ .build();
+ table.newAppend().appendFile(dataFile).commit();
+ table.updateSpec().removeField(partitionName).commit();
+
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> ops.dropNestedColumn("db1", "d_old_spec", path("s",
"a")));
+ Assertions.assertTrue(ex.getMessage().contains("used by an old
partition spec"), ex.getMessage());
+ Assertions.assertNotNull(reload("d_old_spec").findField("s.a"));
+ }
+
+ @Test
+ public void testDropNestedFieldUsedByCurrentFormatV1VoidSpecFailsLoud() {
+ Schema schema = flatNestedSchema();
+ PartitionSpec initialSpec =
PartitionSpec.builderFor(schema).identity("s.a").build();
+ createTable("d_v1_void", schema, initialSpec,
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "1"));
+ Table table = ops.loadTable("db1", "d_v1_void");
+ int oldSpecId = table.spec().specId();
+
table.updateSpec().removeField(table.spec().fields().get(0).name()).commit();
+
+ // Simulate metadata cleanup so only the v1 current void field retains
the source ID. Ignoring the
+ // current spec would let schema deletion corrupt metadata-table
schema construction.
+ table.refresh();
+ TableOperations tableOps = ((HasTableOperations) table).operations();
+ TableMetadata base = tableOps.current();
+ TableMetadata.Builder builder = TableMetadata.buildFrom(base);
+ new
MetadataUpdate.RemovePartitionSpecs(Set.of(oldSpecId)).applyTo(builder);
+ tableOps.commit(base, builder.build());
+ table.refresh();
+
+
Assertions.assertTrue(table.spec().fields().get(0).transform().isVoid());
+ Assertions.assertEquals(1, table.specs().size());
+ assertFailsLoud(() -> ops.dropNestedColumn("db1", "d_v1_void",
path("s", "a")),
+ "partition spec");
+ Assertions.assertNotNull(reload("d_v1_void").findField("s.a"));
+ Assertions.assertDoesNotThrow(() ->
MetadataTableUtils.createMetadataTableInstance(
+ ops.loadTable("db1", "d_v1_void"),
MetadataTableType.POSITION_DELETES).schema());
+ }
+
+ @Test
+ public void
testRestDropSurvivesSpecIdRebaseWithoutRemovingConcurrentSpec() {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "drop_me",
Types.IntegerType.get()));
+ createTable("d_concurrent_spec", schema,
+ PartitionSpec.builderFor(schema).identity("id").build(),
Collections.emptyMap());
+ Table original = ops.loadTable("db1", "d_concurrent_spec");
+ String metricsKey = TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX +
"drop_me";
+ original.updateProperties().set(metricsKey, "full").commit();
+ RestTableOperations concurrentOps = new RestTableOperations(
+ ((HasTableOperations) original).operations(), null);
+ Table racedTable = new BaseTable(concurrentOps, original.name());
+
+ IcebergNestedColumnEvolution.dropTopLevelColumn(racedTable, "drop_me");
+
+ Assertions.assertEquals(1, concurrentOps.commitAttempts);
+ Assertions.assertTrue(concurrentOps.requirements.stream()
+
.anyMatch(UpdateRequirement.AssertLastAssignedPartitionId.class::isInstance));
+ Assertions.assertFalse(concurrentOps.clientChanges.stream()
+
.anyMatch(MetadataUpdate.RemovePartitionSpecs.class::isInstance));
+ original.refresh();
+ Assertions.assertNull(original.schema().findField("drop_me"));
+ Assertions.assertFalse(original.properties().containsKey(metricsKey),
+ "the REST path must preserve SchemaUpdate's column-property
cleanup");
+ Assertions.assertEquals(3, original.specs().size(),
+ "both the rebased concurrent spec and the durable fence must
be retained");
+
Assertions.assertTrue(original.specs().values().stream().anyMatch(PartitionSpec::isUnpartitioned));
+ Assertions.assertTrue(original.specs().values().stream().flatMap(spec
-> spec.fields().stream())
+ .anyMatch(field ->
field.name().startsWith("_doris_schema_drop_fence_")
+ && field.sourceId() == schema.findField("id").fieldId()
+ && field.transform().isVoid()));
+ }
+
+ @Test
+ public void testRestDropRejectsConcurrentSpecUsingDropTarget() {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "drop_me",
Types.IntegerType.get()));
+ createTable("d_target_spec_race", schema);
+ Table original = ops.loadTable("db1", "d_target_spec_race");
+ RestTableOperations concurrentOps = new RestTableOperations(
+ ((HasTableOperations) original).operations(), "drop_me");
+ Table racedTable = new BaseTable(concurrentOps, original.name());
+
+ Assertions.assertThrows(CommitFailedException.class,
+ () ->
IcebergNestedColumnEvolution.dropTopLevelColumn(racedTable, "drop_me"));
+
+ Assertions.assertEquals(1, concurrentOps.commitAttempts);
+ original.refresh();
+ Assertions.assertNotNull(original.schema().findField("drop_me"));
+ Assertions.assertTrue(original.specs().values().stream().flatMap(spec
-> spec.fields().stream())
+ .anyMatch(field -> field.sourceId() ==
schema.findField("drop_me").fieldId()));
+ }
+
+ @Test
+ public void testRestFormatV1DropFailsBeforeCommit() {
+ Schema schema = new Schema(
+ Types.NestedField.required(1, "id", Types.LongType.get()),
+ Types.NestedField.optional(2, "drop_me",
Types.IntegerType.get()));
+ createTable("d_rest_v1", schema, PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "1"));
+ Table original = ops.loadTable("db1", "d_rest_v1");
+ RestTableOperations concurrentOps = new RestTableOperations(
+ ((HasTableOperations) original).operations(), null);
+
+ assertFailsLoud(() -> IcebergNestedColumnEvolution.dropTopLevelColumn(
+ new BaseTable(concurrentOps, original.name()), "drop_me"),
"format-v1");
+
+ Assertions.assertEquals(0, concurrentOps.commitAttempts);
+ Assertions.assertNotNull(original.schema().findField("drop_me"));
+ }
+
+ @Test
+ public void testDropAllowsSchemaContainingOldSyntheticFenceName() {
+ Schema schema = new Schema(
+ Types.NestedField.optional(1, "_doris_schema_drop_fence_1000",
Types.StringType.get()),
+ Types.NestedField.optional(2, "drop_me",
Types.IntegerType.get()));
+ createTable("d_fence_name", schema);
+ Table original = ops.loadTable("db1", "d_fence_name");
+ RestTableOperations restOps = new RestTableOperations(
+ ((HasTableOperations) original).operations(), null);
+
+ IcebergNestedColumnEvolution.dropTopLevelColumn(
+ new BaseTable(restOps, original.name()), "drop_me");
+
+ original.refresh();
+
Assertions.assertNotNull(original.schema().findField("_doris_schema_drop_fence_1000"));
+ Assertions.assertNull(original.schema().findField("drop_me"));
+ Assertions.assertTrue(original.specs().values().stream().flatMap(spec
-> spec.fields().stream())
+ .anyMatch(field ->
field.name().equals("_doris_schema_drop_fence_1001")));
+ }
+
+ @Test
+ public void testFormatV1DropPreservesFirstPartitionFieldId() {
+ createTable("d_v1_first_partition", flatNestedSchema(),
PartitionSpec.unpartitioned(),
+ Collections.singletonMap(TableProperties.FORMAT_VERSION, "1"));
+
+ ops.dropNestedColumn("db1", "d_v1_first_partition", path("s", "a"));
+ Table table = ops.loadTable("db1", "d_v1_first_partition");
+ Assertions.assertDoesNotThrow(() ->
table.updateSpec().addField("s.x").commit());
+ Assertions.assertEquals(1000, table.spec().fields().get(0).fieldId());
+ }
+
+ @Test
+ public void testDropRemovesColumnScopedWriterProperties() {
+ createTable("d_column_props", flatNestedSchema());
+ Table table = ops.loadTable("db1", "d_column_props");
+ String metricsKey = TableProperties.METRICS_MODE_COLUMN_CONF_PREFIX +
"s.a";
+ String bloomKey =
TableProperties.PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX + "s.a";
+ String statsKey = TableProperties.PARQUET_COLUMN_STATS_ENABLED_PREFIX
+ "s.a";
+ table.updateProperties()
+ .set(metricsKey, "full")
+ .set(bloomKey, "true")
+ .set(statsKey, "true")
+ .commit();
+
+ ops.dropNestedColumn("db1", "d_column_props", path("s", "a"));
+
+ Map<String, String> properties = ops.loadTable("db1",
"d_column_props").properties();
+ Assertions.assertFalse(properties.containsKey(metricsKey));
+ Assertions.assertFalse(properties.containsKey(bloomKey));
+ Assertions.assertFalse(properties.containsKey(statsKey));
+ }
+
+ @Test
+ public void testDropRejectsTableReplacementDuringInitialRefresh() {
+ createTable("d_original_identity", flatNestedSchema());
+ createTable("d_replacement_identity", flatNestedSchema());
+ Table original = ops.loadTable("db1", "d_original_identity");
+ Table replacement = ops.loadTable("db1", "d_replacement_identity");
+ RefreshingTableOperations refreshing = new RefreshingTableOperations(
+ ((HasTableOperations) original).operations().current(),
+ ((HasTableOperations) replacement).operations().current(),
+ ((HasTableOperations) original).operations());
+
+ assertFailsLoud(() -> IcebergNestedColumnEvolution.dropColumn(
+ new BaseTable(refreshing, original.name()), path("s", "a")),
"changed concurrently");
+ Assertions.assertEquals(0, refreshing.commitAttempts);
+ }
+
+ @Test
+ public void testDropRejectsSamePathFieldReplacementDuringRefresh() {
+ createTable("d_field_identity", flatNestedSchema());
+ Table table = ops.loadTable("db1", "d_field_identity");
+ TableOperations delegate = ((HasTableOperations) table).operations();
+ TableMetadata loaded = delegate.current();
+ int originalFieldId = loaded.schema().findField("s.a").fieldId();
+ table.updateSchema().deleteColumn("s.a").commit();
+ table.updateSchema().addColumn("s", "a",
Types.IntegerType.get()).commit();
+ TableMetadata replacement = delegate.refresh();
+ Assertions.assertNotEquals(originalFieldId,
replacement.schema().findField("s.a").fieldId());
+ RefreshingTableOperations refreshing = new
RefreshingTableOperations(loaded, replacement, delegate);
+
+ assertFailsLoud(() -> IcebergNestedColumnEvolution.dropColumn(
+ new BaseTable(refreshing, table.name()), path("s", "a")),
"changed concurrently");
+ Assertions.assertEquals(0, refreshing.commitAttempts);
+ }
+
+ @Test
+ public void testDropParentRejectsSubtreeReplacementDuringRefresh() {
+ createTable("d_subtree_identity", flatNestedSchema());
+ Table table = ops.loadTable("db1", "d_subtree_identity");
+ TableOperations delegate = ((HasTableOperations) table).operations();
+ TableMetadata loaded = delegate.current();
+ table.updateSchema().deleteColumn("s.a").commit();
+ table.updateSchema().addColumn("s", "a",
Types.IntegerType.get()).commit();
+ RefreshingTableOperations refreshing = new RefreshingTableOperations(
+ loaded, delegate.refresh(), delegate);
+
+ assertFailsLoud(() -> IcebergNestedColumnEvolution.dropTopLevelColumn(
+ new BaseTable(refreshing, table.name()), "s"), "changed
concurrently");
+ Assertions.assertEquals(0, refreshing.commitAttempts);
+ }
+
+ /** Simulates REST applying the client updates after a concurrent
partition-spec commit. */
+ private static final class RestTableOperations implements TableOperations {
+ private final TableOperations delegate;
+ private final String sourceColumn;
+ private boolean injectConcurrentSpec = true;
+ private int commitAttempts;
+ private List<UpdateRequirement> requirements = Collections.emptyList();
+ private List<MetadataUpdate> clientChanges = Collections.emptyList();
+
+ private RestTableOperations(TableOperations delegate, String
sourceColumn) {
+ this.delegate = delegate;
+ this.sourceColumn = sourceColumn;
+ }
+
+ @Override
+ public TableMetadata current() {
+ return delegate.current();
+ }
+
+ @Override
+ public TableMetadata refresh() {
+ return delegate.refresh();
+ }
+
+ @Override
+ public void commit(TableMetadata base, TableMetadata metadata) {
+ commitAttempts++;
+ if (!injectConcurrentSpec) {
+ delegate.commit(base, metadata);
+ return;
+ }
+ injectConcurrentSpec = false;
+ clientChanges = metadata.changes();
+ requirements = UpdateRequirements.forUpdateTable(base,
clientChanges);
+
+ int newSpecId =
base.specs().stream().mapToInt(PartitionSpec::specId).max().orElse(-1) + 1;
+ PartitionSpec concurrentSpec = sourceColumn == null
+ ?
PartitionSpec.builderFor(base.schema()).withSpecId(newSpecId).build()
+ :
PartitionSpec.builderFor(base.schema()).withSpecId(newSpecId).identity(sourceColumn).build();
+ TableMetadata.Builder concurrent =
TableMetadata.buildFrom(base).addPartitionSpec(concurrentSpec);
+ delegate.commit(base, concurrent.build());
+ TableMetadata rebased = delegate.refresh();
+ for (UpdateRequirement requirement : requirements) {
+ requirement.validate(rebased);
+ }
+ TableMetadata.Builder rebasedUpdate =
TableMetadata.buildFrom(rebased);
+ for (MetadataUpdate update : clientChanges) {
+ update.applyTo(rebasedUpdate);
+ }
+ delegate.commit(rebased, rebasedUpdate.build());
+ }
+
+ @Override
+ public FileIO io() {
+ return delegate.io();
+ }
+
+ @Override
+ public EncryptionManager encryption() {
+ return delegate.encryption();
+ }
+
+ @Override
+ public String metadataFileLocation(String fileName) {
+ return delegate.metadataFileLocation(fileName);
+ }
+
+ @Override
+ public LocationProvider locationProvider() {
+ return delegate.locationProvider();
+ }
+ }
+
+ private static final class RefreshingTableOperations implements
TableOperations {
+ private final TableMetadata loaded;
+ private final TableMetadata refreshed;
+ private final TableOperations delegate;
+ private boolean didRefresh;
+ private int commitAttempts;
+
+ private RefreshingTableOperations(
+ TableMetadata loaded, TableMetadata refreshed, TableOperations
delegate) {
+ this.loaded = loaded;
+ this.refreshed = refreshed;
+ this.delegate = delegate;
+ }
+
+ @Override
+ public TableMetadata current() {
+ return didRefresh ? refreshed : loaded;
+ }
+
+ @Override
+ public TableMetadata refresh() {
+ didRefresh = true;
+ return refreshed;
+ }
+
+ @Override
+ public void commit(TableMetadata base, TableMetadata metadata) {
+ commitAttempts++;
+ }
+
+ @Override
+ public FileIO io() {
+ return delegate.io();
+ }
+
+ @Override
+ public EncryptionManager encryption() {
+ return delegate.encryption();
+ }
+
+ @Override
+ public String metadataFileLocation(String fileName) {
+ return delegate.metadataFileLocation(fileName);
+ }
+
+ @Override
+ public LocationProvider locationProvider() {
+ return delegate.locationProvider();
+ }
+ }
+
+ @Test
+ public void testDropParentOfHistoricalPartitionFieldFailsLoud() {
+ createTable("d_old_spec_parent", flatNestedSchema());
+ Table table = ops.loadTable("db1", "d_old_spec_parent");
+ table.updateSpec().addField("s.a").commit();
+ String partitionName = table.spec().fields().get(0).name();
+ DataFile dataFile = DataFiles.builder(table.spec())
+ .withPath("file:/warehouse/d_old_spec_parent/data.parquet")
+ .withFileSizeInBytes(1)
+ .withRecordCount(1)
+ .withPartitionPath(partitionName + "=1")
+ .build();
+ table.newAppend().appendFile(dataFile).commit();
+ table.updateSpec().removeField(partitionName).commit();
+
+ DorisConnectorException ex =
Assertions.assertThrows(DorisConnectorException.class,
+ () -> ops.dropColumn("db1", "d_old_spec_parent", "s"));
+ Assertions.assertTrue(ex.getMessage().contains("used by an old
partition spec"), ex.getMessage());
+ Assertions.assertNotNull(reload("d_old_spec_parent").findField("s"));
+ }
+
@Test
public void testRenameNestedStructField() {
createTable("d_rename", flatNestedSchema());
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]