This is an automated email from the ASF dual-hosted git repository.
suxiaogang223 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 49f3cf87d8f [fix](iceberg) Preserve assigned field IDs for older REST
catalogs (#69757)
49f3cf87d8f is described below
commit 49f3cf87d8f4667555f3999e3de28ebb1e05f8ce
Author: Socrates <[email protected]>
AuthorDate: Thu Oct 29 10:22:07 2026 +0800
[fix](iceberg) Preserve assigned field IDs for older REST catalogs (#69757)
### What problem does this PR solve?
Issue Number: N/A
Related issue: https://github.com/apache/iceberg/issues/13850
Problem Summary:
With Iceberg SDK 1.9+ (including master's 1.12.0), dropping the
highest-ID column sends the surviving schema's maximum field ID as
`add-schema.last-column-id`. Iceberg REST 1.6.0 validates this
deprecated field against the table's historical assigned-ID high-water
mark, so a table with IDs 1, 2, 3 rejects dropping ID 3 with `Invalid
last column ID: 2 < 3`. Dropping ID 2 from a two-column table fails in
the same way.
Configure the standard REST HTTP client with SDK-compatible Jackson
codecs that preserve the base assigned-ID requirement in this legacy
wire field. Track the maximum across successive schema updates,
independently for each table in a multi-table commit. Keep field/schema
IDs, commit requirements, authentication, retry handling, and existing
DROP COLUMN partition-spec fencing unchanged. Non-schema commits use the
upstream request parser directly.
### Release note
Fix dropping the highest-ID Iceberg column through older REST catalogs,
including Iceberg REST 1.6.0.
### Check List (For Author)
- Test:
- [ ] Unit Test: 10 added covering both drops, historical IDs, repeated
schema updates, create/replace, multi-table isolation, unchanged
payloads, concurrent schema changes, and existing response codecs. Not
run locally.
- [ ] Regression test: extend the REST 1.6.0 HDFS fixture suite with
both drops, repeated drops, add/drop, and subsequent inserts. Not run
locally.
- Validation performed: repository Checkstyle 9.3 on all three changed
Java files; `git diff --check`. Compilation and test execution are left
to CI as requested; no local build dependencies or artifacts were
created.
- Behavior changed: Yes. Legacy REST servers receive a nondecreasing
historical `last-column-id` for schema commits.
- Does this need documentation: No.
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label
---
.../doris/connector/iceberg/IcebergConnector.java | 8 +-
.../iceberg/IcebergRestSchemaCompatibility.java | 115 ++++++++++++++
.../IcebergRestSchemaCompatibilityTest.java | 169 +++++++++++++++++++++
.../iceberg_rest_on_hdfs.groovy | 18 +++
4 files changed, 309 insertions(+), 1 deletion(-)
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
index 12c88ced2f8..3a9dd213dcf 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergConnector.java
@@ -65,8 +65,10 @@ import org.apache.iceberg.hive.HiveCatalog;
import org.apache.iceberg.hive.HiveHadoopUtil;
import org.apache.iceberg.io.FileIO;
import org.apache.iceberg.io.SupportsStorageCredentials;
+import org.apache.iceberg.rest.HTTPClient;
import org.apache.iceberg.rest.RESTCatalog;
import org.apache.iceberg.rest.RESTSessionCatalog;
+import org.apache.iceberg.rest.RESTUtil;
import org.apache.iceberg.util.ThreadPools;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -1209,7 +1211,11 @@ public class IcebergConnector implements Connector {
/** Builds and initializes a bare Iceberg {@link RESTSessionCatalog}
(fresh REST client + OAuth2 token fetch). */
private RESTSessionCatalog newRestSessionCatalog(String catalogName,
Map<String, String> props,
Configuration conf) {
- RESTSessionCatalog sessionCatalog = new RESTSessionCatalog();
+ RESTSessionCatalog sessionCatalog = new RESTSessionCatalog(config ->
HTTPClient.builder(config)
+ .uri(config.get(CatalogProperties.URI))
+ .withHeaders(RESTUtil.configHeaders(config))
+
.withObjectMapper(IcebergRestSchemaCompatibility.objectMapper())
+ .build(), null);
CatalogUtil.configureHadoopConf(sessionCatalog, conf);
sessionCatalog.initialize(catalogName, props);
return sessionCatalog;
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergRestSchemaCompatibility.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergRestSchemaCompatibility.java
new file mode 100644
index 00000000000..bf281b9c6ab
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergRestSchemaCompatibility.java
@@ -0,0 +1,115 @@
+// 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.doris.connector.iceberg;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.PropertyAccessor;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonFactoryBuilder;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.JsonSerializer;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.PropertyNamingStrategies;
+import com.fasterxml.jackson.databind.SerializerProvider;
+import com.fasterxml.jackson.databind.module.SimpleModule;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.iceberg.MetadataUpdate;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.rest.RESTSerializers;
+import org.apache.iceberg.rest.requests.CommitTransactionRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequestParser;
+import org.apache.iceberg.util.JsonUtil;
+
+import java.io.IOException;
+import java.util.List;
+
+/** Preserves the assigned field ID high-water mark for Iceberg 1.6 REST
servers. */
+final class IcebergRestSchemaCompatibility {
+ private IcebergRestSchemaCompatibility() {
+ }
+
+ static ObjectMapper objectMapper() {
+ // Match the SDK's package-private RESTObjectMapper, including all
request/response codecs.
+ ObjectMapper mapper = new ObjectMapper(new JsonFactoryBuilder()
+ .configure(JsonFactory.Feature.INTERN_FIELD_NAMES, false)
+ .configure(JsonFactory.Feature.FAIL_ON_SYMBOL_HASH_OVERFLOW,
false).build());
+ mapper.setVisibility(PropertyAccessor.FIELD,
JsonAutoDetect.Visibility.ANY);
+ mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
false);
+ mapper.setPropertyNamingStrategy(new
PropertyNamingStrategies.KebabCaseStrategy());
+ RESTSerializers.registerAll(mapper);
+ SimpleModule module = new
SimpleModule("doris-iceberg-rest-schema-compatibility");
+ module.addSerializer(UpdateTableRequest.class, new
UpdateTableSerializer());
+ // The SDK transaction serializer invokes UpdateTableRequestParser
directly, bypassing Jackson codecs.
+ module.addSerializer(CommitTransactionRequest.class, new
CommitTransactionSerializer());
+ mapper.registerModule(module);
+ return mapper;
+ }
+
+ private static void writeUpdate(UpdateTableRequest request, JsonGenerator
generator) throws IOException {
+ List<MetadataUpdate> updates = request.updates();
+ boolean addsSchema = updates.stream().anyMatch(update -> update
instanceof MetadataUpdate.AddSchema);
+ if (!addsSchema) {
+ UpdateTableRequestParser.toJson(request, generator);
+ return;
+ }
+
+ int lastColumnId = 0;
+ for (UpdateRequirement requirement : request.requirements()) {
+ if (requirement instanceof
UpdateRequirement.AssertLastAssignedFieldId) {
+ lastColumnId = ((UpdateRequirement.AssertLastAssignedFieldId)
requirement).lastAssignedFieldId();
+ }
+ }
+ JsonNode json =
JsonUtil.mapper().readTree(UpdateTableRequestParser.toJson(request));
+ for (int index = 0; index < updates.size(); index++) {
+ MetadataUpdate update = updates.get(index);
+ if (update instanceof MetadataUpdate.AddSchema) {
+ // SDK 1.9+ writes schema.highestFieldId(), which can decrease
after DROP COLUMN.
+ // REST 1.6 validates this deprecated field against the
table's historical last-column-id.
+ // Keep it monotonic across every schema change in a single
commit as well.
+ lastColumnId = Math.max(lastColumnId,
((MetadataUpdate.AddSchema) update).schema().highestFieldId());
+ ((ObjectNode)
json.get("updates").get(index)).put("last-column-id", lastColumnId);
+ }
+ }
+ generator.writeTree(json);
+ }
+
+ private static class UpdateTableSerializer extends
JsonSerializer<UpdateTableRequest> {
+ @Override
+ public void serialize(UpdateTableRequest request, JsonGenerator
generator, SerializerProvider provider)
+ throws IOException {
+ writeUpdate(request, generator);
+ }
+ }
+
+ private static class CommitTransactionSerializer extends
JsonSerializer<CommitTransactionRequest> {
+ @Override
+ public void serialize(CommitTransactionRequest request, JsonGenerator
generator, SerializerProvider provider)
+ throws IOException {
+ generator.writeStartObject();
+ generator.writeArrayFieldStart("table-changes");
+ for (UpdateTableRequest tableChange : request.tableChanges()) {
+ writeUpdate(tableChange, generator);
+ }
+ generator.writeEndArray();
+ generator.writeEndObject();
+ }
+ }
+}
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRestSchemaCompatibilityTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRestSchemaCompatibilityTest.java
new file mode 100644
index 00000000000..58b9222498d
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergRestSchemaCompatibilityTest.java
@@ -0,0 +1,169 @@
+// 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.doris.connector.iceberg;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.iceberg.MetadataUpdate;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.UpdateRequirement;
+import org.apache.iceberg.UpdateRequirements;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.rest.requests.CommitTransactionRequest;
+import org.apache.iceberg.rest.requests.CommitTransactionRequestParser;
+import org.apache.iceberg.rest.requests.UpdateTableRequest;
+import org.apache.iceberg.rest.requests.UpdateTableRequestParser;
+import org.apache.iceberg.rest.responses.ConfigResponse;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+class IcebergRestSchemaCompatibilityTest {
+ private final ObjectMapper mapper =
IcebergRestSchemaCompatibility.objectMapper();
+
+ @Test
+ void testDropHighestOfThreeFields() throws Exception {
+ assertDropHighestField(3);
+ }
+
+ @Test
+ void testDropHighestOfTwoFields() throws Exception {
+ assertDropHighestField(2);
+ }
+
+ private void assertDropHighestField(int count) throws Exception {
+ TableMetadata base = metadata(count);
+ UpdateTableRequest request = update(base, schema(count - 1));
+ String original = UpdateTableRequestParser.toJson(request);
+ JsonNode expected = mapper.readTree(original);
+ // This is the only wire change: preserve the historical high-water
mark, not the surviving maximum ID.
+ ((ObjectNode) expected.get("updates").get(0)).put("last-column-id",
count);
+ JsonNode actual = wire(request);
+ Assertions.assertEquals(expected, actual);
+ Assertions.assertEquals(original,
UpdateTableRequestParser.toJson(request));
+ Assertions.assertEquals(count - 1,
actual.at("/updates/0/schema/fields").size());
+ for (int index = 0; index < count - 1; index++) {
+ Assertions.assertEquals(index + 1,
actual.at("/updates/0/schema/fields/" + index + "/id").asInt());
+ }
+ }
+
+ @Test
+ void testRetainHistoricalIdAfterEarlierDrop() throws Exception {
+ TableMetadata base =
TableMetadata.buildFrom(metadata(3)).addSchema(schema(2)).build();
+ Assertions.assertEquals(3, base.lastColumnId());
+ Assertions.assertEquals(3, wire(update(base,
schema(1))).at("/updates/0/last-column-id").asInt());
+ }
+
+ @Test
+ void testAddThenDropWithinOneCommitDoesNotLowerId() throws Exception {
+ JsonNode json = wire(update(metadata(3), schema(4), schema(2)));
+ Assertions.assertEquals(4,
json.at("/updates/0/last-column-id").asInt());
+ Assertions.assertEquals(4,
json.at("/updates/1/last-column-id").asInt());
+ }
+
+ @Test
+ void testCreateTracksIdsAcrossSchemaUpdates() throws Exception {
+ List<MetadataUpdate> updates = Arrays.asList(new
MetadataUpdate.AddSchema(schema(4)),
+ new MetadataUpdate.AddSchema(schema(2)));
+ JsonNode json = wire(new
UpdateTableRequest(UpdateRequirements.forCreateTable(updates), updates));
+ Assertions.assertEquals(4,
json.at("/updates/0/last-column-id").asInt());
+ Assertions.assertEquals(4,
json.at("/updates/1/last-column-id").asInt());
+ }
+
+ @Test
+ void testReplaceRetainsBaseAssignedId() throws Exception {
+ TableMetadata base = metadata(3);
+ List<MetadataUpdate> updates = Collections.singletonList(new
MetadataUpdate.AddSchema(schema(2)));
+ UpdateTableRequest request = new
UpdateTableRequest(UpdateRequirements.forReplaceTable(base, updates), updates);
+ Assertions.assertEquals(3,
wire(request).at("/updates/0/last-column-id").asInt());
+ }
+
+ @Test
+ void testMultiTableCommitKeepsIndependentHighWaterMarks() throws Exception
{
+ UpdateTableRequest first = update(metadata(3), schema(4), schema(2));
+ UpdateTableRequest second = update(metadata(2), schema(1));
+ second = UpdateTableRequest.create(TableIdentifier.of("db", "other"),
second.requirements(), second.updates());
+ CommitTransactionRequest request = new
CommitTransactionRequest(Arrays.asList(first, second));
+ JsonNode expected =
mapper.readTree(CommitTransactionRequestParser.toJson(request));
+ ((ObjectNode)
expected.at("/table-changes/0/updates/0")).put("last-column-id", 4);
+ ((ObjectNode)
expected.at("/table-changes/0/updates/1")).put("last-column-id", 4);
+ ((ObjectNode)
expected.at("/table-changes/1/updates/0")).put("last-column-id", 2);
+ Assertions.assertEquals(expected,
mapper.readTree(mapper.writeValueAsString(request)));
+ }
+
+ @Test
+ void testNonSchemaCommitIsUnchanged() throws Exception {
+ UpdateTableRequest request =
UpdateTableRequest.create(TableIdentifier.of("db", "table"),
+ Collections.singletonList(new
UpdateRequirement.AssertTableUUID(metadata(3).uuid())),
+ Collections.singletonList(new
MetadataUpdate.SetProperties(Collections.singletonMap("key", "value"))));
+
Assertions.assertEquals(mapper.readTree(UpdateTableRequestParser.toJson(request)),
wire(request));
+ }
+
+ @Test
+ void testConcurrentSchemaChangeStillFailsRequirements() throws Exception {
+ UpdateTableRequest request = update(metadata(3), schema(2));
+ UpdateTableRequest decoded =
UpdateTableRequestParser.fromJson(mapper.writeValueAsString(request));
+ TableMetadata concurrent =
TableMetadata.buildFrom(metadata(3)).addSchema(schema(4)).build();
+ UpdateRequirement fieldIdRequirement = decoded.requirements().stream()
+ .filter(requirement -> requirement instanceof
UpdateRequirement.AssertLastAssignedFieldId)
+ .findFirst().orElseThrow(AssertionError::new);
+ Assertions.assertThrows(CommitFailedException.class, () ->
fieldIdRequirement.validate(concurrent));
+ }
+
+ @Test
+ void testOtherRestResponsesKeepSdkCodecsAndUnknownFieldTolerance() throws
Exception {
+ ConfigResponse config =
mapper.readValue("{\"defaults\":{\"key\":\"value\"},"
+ + "\"overrides\":{},\"future-field\":true}",
ConfigResponse.class);
+ Assertions.assertEquals("value", config.defaults().get("key"));
+ }
+
+ private JsonNode wire(UpdateTableRequest request) throws Exception {
+ return mapper.readTree(mapper.writeValueAsString(request));
+ }
+
+ private static UpdateTableRequest update(TableMetadata base, Schema...
schemas) {
+ List<MetadataUpdate> updates = new ArrayList<>();
+ for (Schema schema : schemas) {
+ updates.add(new MetadataUpdate.AddSchema(schema));
+ }
+ return UpdateTableRequest.create(TableIdentifier.of("db", "table"),
+ UpdateRequirements.forUpdateTable(base, updates), updates);
+ }
+
+ private static TableMetadata metadata(int count) {
+ return TableMetadata.newTableMetadata(schema(count),
PartitionSpec.unpartitioned(), "s3://bucket/table",
+ Collections.emptyMap());
+ }
+
+ private static Schema schema(int count) {
+ List<Types.NestedField> fields = new ArrayList<>();
+ for (int id = 1; id <= count; id++) {
+ fields.add(Types.NestedField.optional(id, "c" + id,
Types.IntegerType.get()));
+ }
+ return new Schema(fields);
+ }
+}
diff --git
a/regression-test/suites/external_table_p2/refactor_catalog_param/iceberg_rest_on_hdfs.groovy
b/regression-test/suites/external_table_p2/refactor_catalog_param/iceberg_rest_on_hdfs.groovy
index abb02093cbd..e78f52a5851 100644
---
a/regression-test/suites/external_table_p2/refactor_catalog_param/iceberg_rest_on_hdfs.groovy
+++
b/regression-test/suites/external_table_p2/refactor_catalog_param/iceberg_rest_on_hdfs.groovy
@@ -49,6 +49,24 @@ suite("iceberg_rest_on_hdfs", "p2,external") {
sql """
use ${db_name};
"""
+ // The HDFS REST fixture runs tabulario/iceberg-rest:1.6.0. Dropping
the highest field ID must
+ // preserve the table's historical last-column-id in add-schema
commits to this older server.
+ sql """DROP TABLE IF EXISTS rest_drop_highest_three"""
+ sql """CREATE TABLE rest_drop_highest_three (c1 INT, c2 INT, c3 INT)"""
+ sql """INSERT INTO rest_drop_highest_three VALUES (1, 2, 3)"""
+ sql """ALTER TABLE rest_drop_highest_three DROP COLUMN c3"""
+ sql """INSERT INTO rest_drop_highest_three VALUES (4, 5)"""
+ sql """ALTER TABLE rest_drop_highest_three DROP COLUMN c2"""
+ sql """ALTER TABLE rest_drop_highest_three ADD COLUMN c4 INT"""
+ sql """ALTER TABLE rest_drop_highest_three DROP COLUMN c4"""
+ sql """INSERT INTO rest_drop_highest_three VALUES (6)"""
+
+ sql """DROP TABLE IF EXISTS rest_drop_highest_two"""
+ sql """CREATE TABLE rest_drop_highest_two (c1 INT, c2 INT)"""
+ sql """INSERT INTO rest_drop_highest_two VALUES (1, 2)"""
+ sql """ALTER TABLE rest_drop_highest_two DROP COLUMN c2"""
+ sql """INSERT INTO rest_drop_highest_two VALUES (3)"""
+
// ======= BASIC TABLE OPERATIONS TEST =======
def table_name = prefix + "_table"
sql """
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]