This is an automated email from the ASF dual-hosted git repository.

corgy-w pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 50e37b5e09 [Fix][Connectors-v2] Require explicit primary keys for 
Iceberg upsert mode (#10762)
50e37b5e09 is described below

commit 50e37b5e092d5646297062c3d24ba3c70532214d
Author: Ricky Makhija <[email protected]>
AuthorDate: Wed May 13 07:25:03 2026 +0530

    [Fix][Connectors-v2] Require explicit primary keys for Iceberg upsert mode 
(#10762)
---
 .../introduction/concepts/incompatible-changes.md  |  21 +++
 .../introduction/concepts/incompatible-changes.md  |  17 +++
 .../iceberg/config/IcebergSinkConfig.java          |  15 ++
 .../seatunnel/iceberg/sink/IcebergSink.java        |   6 -
 .../seatunnel/iceberg/utils/SchemaUtils.java       |  49 +++++--
 .../seatunnel/iceberg/utils/SchemaUtilsTest.java   | 161 +++++++++++++++++++++
 6 files changed, 253 insertions(+), 16 deletions(-)

diff --git a/docs/en/introduction/concepts/incompatible-changes.md 
b/docs/en/introduction/concepts/incompatible-changes.md
index 7cdb776dde..93fb3b75eb 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -35,6 +35,27 @@ You need to check this document before you upgrade to 
related version.
 
 ### Connector Changes
 
+- **Breaking Change: Iceberg Connector — source table primary key is no longer 
silently inherited**
+  - **Affected component**: `seatunnel-connectors-v2/connector-iceberg`
+  - **Description**: `SchemaUtils.toIcebergSchema()` previously fell back to 
the CDC source
+    table's primary key when `iceberg.table.primary-keys` was not explicitly 
configured. This
+    silently set `identifier-field-ids` on auto-created Iceberg tables, 
activating equality-delete
+    semantics and causing silent INSERT data loss in append-only CDC pipelines
+    (see [#10747](https://github.com/apache/seatunnel/issues/10747)). The 
fallback has been
+    removed.
+  - **Impact**: Jobs that set `iceberg.table.upsert-mode-enabled=true` without 
an explicit
+    `iceberg.table.primary-keys` will now fail at startup with a clear 
`IllegalArgumentException`.
+    Jobs that relied on implicit PK inheritance to drive upsert semantics must 
now set
+    `iceberg.table.primary-keys` explicitly.
+  - **Migration Guide**:
+    - **Upsert mode jobs**: Add `iceberg.table.primary-keys = "<your key 
columns>"` to the Iceberg
+      sink config.
+    - **Append-only CDC jobs**: No action needed — omitting 
`iceberg.table.primary-keys` now
+      correctly routes writes through the pure append writer with no equality 
deletes.
+    - **Existing Iceberg tables** that already have `identifier-field-ids` 
stored in their
+      Glue/Hive metastore schema are not affected at runtime; only newly 
auto-created tables change
+      behavior.
+
 ### Transform Changes
 
 - **[BREAKING]** SQL Transform `PARSEDATETIME`, `TO_DATE`, and `IS_DATE` 
functions now only accept whitelisted datetime format patterns. Custom format 
patterns that were previously accepted will now fail at runtime. The supported 
patterns are:
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md 
b/docs/zh/introduction/concepts/incompatible-changes.md
index 784729cec6..900fdfd126 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -34,6 +34,23 @@
 
 ### 连接器变更
 
+- **破坏性变更:Iceberg 连接器 — 不再自动继承源表主键**
+  - **影响范围**:`seatunnel-connectors-v2/connector-iceberg`
+  - **变更说明**:当未显式配置 `iceberg.table.primary-keys` 
时,`SchemaUtils.toIcebergSchema()`
+    以前会回退使用 CDC 源表的主键。这会静默地将 `identifier-field-ids` 设置到自动创建的 Iceberg
+    表上,激活等值删除语义,导致 append-only CDC 管道中的 INSERT 数据静默丢失
+    (详见 [#10747](https://github.com/apache/seatunnel/issues/10747))。该回退行为已被移除。
+  - **影响**:使用 `iceberg.table.upsert-mode-enabled=true` 但未显式配置
+    `iceberg.table.primary-keys` 的任务,启动时将抛出 `IllegalArgumentException` 并快速失败。
+    依赖隐式 PK 继承来实现 upsert 语义的任务,需要显式设置 `iceberg.table.primary-keys`。
+  - **迁移指南**:
+    - **Upsert 模式任务**:在 Iceberg sink 配置中添加
+      `iceberg.table.primary-keys = "<主键列名>"`。
+    - **Append-only CDC 任务**:无需任何操作 — 不配置 `iceberg.table.primary-keys`
+      现在会正确使用纯 append writer,不会产生等值删除文件。
+    - **已存在的 Iceberg 表**(Glue/Hive 元数据中已有 `identifier-field-ids`)在运行时不受影响;
+      只有 sink 新建的表会改变行为。
+
 ### 转换变更
 
 - **[BREAKING]** SQL Transform 的 `PARSEDATETIME`、`TO_DATE` 和 `IS_DATE` 
函数现在只接受白名单中的日期时间格式模式。以前接受的自定义格式模式现在将在运行时失败。支持的模式有:
diff --git 
a/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/config/IcebergSinkConfig.java
 
b/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/config/IcebergSinkConfig.java
index 6567208e4e..4fa9806eeb 100644
--- 
a/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/config/IcebergSinkConfig.java
+++ 
b/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/config/IcebergSinkConfig.java
@@ -81,6 +81,21 @@ public class IcebergSinkConfig extends IcebergCommonConfig {
         this.dataSaveMode = 
readonlyConfig.get(IcebergSinkOptions.DATA_SAVE_MODE);
         this.dataSaveModeSQL = 
readonlyConfig.get(IcebergSinkOptions.DATA_SAVE_MODE_CUSTOM_SQL);
         this.commitBranch = 
readonlyConfig.get(IcebergSinkOptions.TABLES_DEFAULT_COMMIT_BRANCH);
+        if (this.upsertModeEnabled
+                && readonlyConfig
+                        .getOptional(IcebergSinkOptions.TABLE_PRIMARY_KEYS)
+                        .map(String::trim)
+                        .map(String::isEmpty)
+                        .orElse(true)) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "'%s' is enabled but '%s' is not explicitly 
configured. "
+                                    + "Upsert mode requires an explicit 
primary-key list. "
+                                    + "The source table primary key is no 
longer inherited "
+                                    + "automatically (see: 
apache/seatunnel#10747).",
+                            
IcebergSinkOptions.TABLE_UPSERT_MODE_ENABLED_PROP.key(),
+                            IcebergSinkOptions.TABLE_PRIMARY_KEYS.key()));
+        }
     }
 
     @VisibleForTesting
diff --git 
a/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/sink/IcebergSink.java
 
b/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/sink/IcebergSink.java
index 936471bfd4..d956ac2582 100644
--- 
a/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/sink/IcebergSink.java
+++ 
b/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/sink/IcebergSink.java
@@ -68,12 +68,6 @@ public class IcebergSink
         this.readonlyConfig = pluginConfig;
         this.config = new IcebergSinkConfig(pluginConfig);
         this.catalogTable = catalogTable;
-        // Reset primary keys if need
-        if (config.getPrimaryKeys().isEmpty()
-                && 
Objects.nonNull(this.catalogTable.getTableSchema().getPrimaryKey())) {
-            this.config.setPrimaryKeys(
-                    
this.catalogTable.getTableSchema().getPrimaryKey().getColumnNames());
-        }
         // reset partition keys if need
         if (config.getPartitionKeys().isEmpty()
                 && Objects.nonNull(this.catalogTable.getPartitionKeys())) {
diff --git 
a/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/utils/SchemaUtils.java
 
b/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/utils/SchemaUtils.java
index 24161c2585..3afc5efeaa 100644
--- 
a/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/utils/SchemaUtils.java
+++ 
b/seatunnel-connectors-v2/connector-iceberg/src/main/java/org/apache/seatunnel/connectors/seatunnel/iceberg/utils/SchemaUtils.java
@@ -24,6 +24,7 @@ import 
org.apache.seatunnel.shade.com.google.common.annotations.VisibleForTestin
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.catalog.Column;
+import org.apache.seatunnel.api.table.catalog.PrimaryKey;
 import org.apache.seatunnel.api.table.catalog.TablePath;
 import org.apache.seatunnel.api.table.catalog.TableSchema;
 import org.apache.seatunnel.api.table.catalog.exception.CatalogException;
@@ -102,8 +103,20 @@ public class SchemaUtils {
             Catalog catalog, TablePath tablePath, CatalogTable table, 
ReadonlyConfig readonlyConfig)
             throws TableAlreadyExistException, DatabaseNotExistException, 
CatalogException {
         TableSchema tableSchema = table.getTableSchema();
-        // Convert to iceberg schema
-        Schema schema = toIcebergSchema(tableSchema, readonlyConfig);
+        // Resolve primary keys: explicit config takes precedence; fall back 
to the CatalogTable's
+        // own PK for this catalog-creation path. This fallback is 
intentionally absent from the
+        // CDC sink path (toIcebergSchema(tableSchema, readonlyConfig)) where 
inheriting the source
+        // table PK would silently activate equality-delete semantics 
(apache/seatunnel#10747).
+        List<String> pkColumns =
+                readonlyConfig
+                        .getOptional(IcebergSinkOptions.TABLE_PRIMARY_KEYS)
+                        .map(e -> IcebergSinkConfig.stringToList(e, ","))
+                        .orElseGet(
+                                () ->
+                                        
Optional.ofNullable(tableSchema.getPrimaryKey())
+                                                
.map(PrimaryKey::getColumnNames)
+                                                
.orElse(Collections.emptyList()));
+        Schema schema = toIcebergSchema(tableSchema, pkColumns);
         // Convert sink config
         IcebergSinkConfig config = new IcebergSinkConfig(readonlyConfig);
         // build auto create table
@@ -161,19 +174,35 @@ public class SchemaUtils {
         return result.get();
     }
 
+    /**
+     * Converts a {@link TableSchema} to an Iceberg {@link Schema} for the CDC 
sink path.
+     *
+     * <p>Identifier fields are set ONLY when {@code 
iceberg.table.primary-keys} is explicitly
+     * configured. The source table's primary key is intentionally NOT 
inherited: silently using the
+     * MySQL PK activates {@code BaseEqualityDeltaWriter}, which emits 
positional deletes for
+     * repeated keys within the same checkpoint window, causing silent data 
loss in append-only CDC
+     * pipelines (apache/seatunnel#10747).
+     *
+     * <p>For explicit catalog table creation where the CatalogTable's own PK 
should be respected,
+     * use {@link #autoCreateTable(Catalog, TablePath, CatalogTable, 
ReadonlyConfig)} which applies
+     * the CatalogTable PK as a fallback.
+     */
     @VisibleForTesting
     @NotNull protected static Schema toIcebergSchema(
             TableSchema tableSchema, ReadonlyConfig readonlyConfig) {
+        List<String> pkColumns =
+                readonlyConfig
+                        .getOptional(IcebergSinkOptions.TABLE_PRIMARY_KEYS)
+                        .map(e -> IcebergSinkConfig.stringToList(e, ","))
+                        .orElse(Collections.emptyList());
+        return toIcebergSchema(tableSchema, pkColumns);
+    }
+
+    @VisibleForTesting
+    static Schema toIcebergSchema(TableSchema tableSchema, List<String> 
primaryKeyColumns) {
         Types.StructType structType = SchemaUtils.toIcebergType(tableSchema);
         Set<Integer> identifierFieldIds =
-                
readonlyConfig.getOptional(IcebergSinkOptions.TABLE_PRIMARY_KEYS)
-                        .map(e -> IcebergSinkConfig.stringToList(e, ","))
-                        .orElseGet(
-                                () ->
-                                        
Optional.ofNullable(tableSchema.getPrimaryKey())
-                                                .map(e -> e.getColumnNames())
-                                                
.orElse(Collections.emptyList()))
-                        .stream()
+                primaryKeyColumns.stream()
                         .map(f -> structType.field(f).fieldId())
                         .collect(Collectors.toSet());
         List<Types.NestedField> fields = new ArrayList<>();
diff --git 
a/seatunnel-connectors-v2/connector-iceberg/src/test/java/org/apache/seatunnel/connectors/seatunnel/iceberg/utils/SchemaUtilsTest.java
 
b/seatunnel-connectors-v2/connector-iceberg/src/test/java/org/apache/seatunnel/connectors/seatunnel/iceberg/utils/SchemaUtilsTest.java
index 6962fd942a..030e2fc66d 100644
--- 
a/seatunnel-connectors-v2/connector-iceberg/src/test/java/org/apache/seatunnel/connectors/seatunnel/iceberg/utils/SchemaUtilsTest.java
+++ 
b/seatunnel-connectors-v2/connector-iceberg/src/test/java/org/apache/seatunnel/connectors/seatunnel/iceberg/utils/SchemaUtilsTest.java
@@ -19,9 +19,14 @@ package 
org.apache.seatunnel.connectors.seatunnel.iceberg.utils;
 
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
 import org.apache.seatunnel.api.table.catalog.CatalogTableUtil;
+import org.apache.seatunnel.api.table.catalog.Column;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.PrimaryKey;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
 import org.apache.seatunnel.api.table.type.BasicType;
 import org.apache.seatunnel.api.table.type.SeaTunnelDataType;
 import org.apache.seatunnel.api.table.type.SeaTunnelRowType;
+import 
org.apache.seatunnel.connectors.seatunnel.iceberg.config.IcebergSinkConfig;
 import 
org.apache.seatunnel.connectors.seatunnel.iceberg.config.IcebergSinkOptions;
 
 import org.apache.iceberg.Schema;
@@ -31,8 +36,10 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
+import java.util.Optional;
 
 class SchemaUtilsTest {
 
@@ -106,5 +113,159 @@ class SchemaUtilsTest {
             Assertions.assertEquals(fieldNames[column.fieldId() - 1], 
column.name());
             Assertions.assertEquals(Boolean.FALSE, column.isRequired());
         }
+        Assertions.assertTrue(
+                schema.identifierFieldIds().isEmpty(),
+                "identifier-field-ids must be empty when 
iceberg.table.primary-keys is not configured");
+    }
+
+    /**
+     * Regression test for the MySQL PK fallback bug.
+     *
+     * <p>Before the fix, {@code SchemaUtils.toIcebergSchema()} fell back to 
{@code
+     * tableSchema.getPrimaryKey()} when {@code iceberg.table.primary-keys} 
was not set, silently
+     * copying the CDC source table PK into {@code identifier-field-ids}. This 
activated {@code
+     * BaseEqualityDeltaWriter}, which emits positional deletes for repeated 
keys within the same
+     * checkpoint window, causing silent INSERT data loss in append-only CDC 
pipelines.
+     *
+     * <p>After the fix, {@code identifier-field-ids} must remain empty when 
the config key is
+     * absent, regardless of whether the source {@link TableSchema} carries a 
primary key.
+     */
+    @Test
+    void testToIcebergSchemaDoesNotInheritSourceTablePkWhenNotConfigured() {
+        String[] fieldNames = new String[] {"id", "name", "value"};
+        SeaTunnelDataType<?>[] dataTypes =
+                new SeaTunnelDataType[] {
+                    BasicType.LONG_TYPE, BasicType.STRING_TYPE, 
BasicType.STRING_TYPE
+                };
+
+        // Build a TableSchema that has a primary key on "id" — simulating a 
CDC source table
+        // (e.g. MySQL) whose PK would previously be silently inherited as 
identifier-field-ids.
+        List<Column> columns =
+                Arrays.asList(
+                        PhysicalColumn.of(
+                                "id", BasicType.LONG_TYPE, (Long) null, false, 
null, null),
+                        PhysicalColumn.of(
+                                "name", BasicType.STRING_TYPE, (Long) null, 
true, null, null),
+                        PhysicalColumn.of(
+                                "value", BasicType.STRING_TYPE, (Long) null, 
true, null, null));
+        PrimaryKey sourcePk = PrimaryKey.of("pk_id", 
Collections.singletonList("id"));
+        TableSchema tableSchemaWithPk =
+                TableSchema.builder()
+                        .columns(columns)
+                        .primaryKey(sourcePk)
+                        .constraintKey(Collections.emptyList())
+                        .build();
+
+        // No iceberg.table.primary-keys in config — append-only CDC pipeline 
scenario
+        ReadonlyConfig readonlyConfig = ReadonlyConfig.fromMap(new 
HashMap<>());
+
+        Schema schema = SchemaUtils.toIcebergSchema(tableSchemaWithPk, 
readonlyConfig);
+
+        Assertions.assertNotNull(schema);
+        Assertions.assertEquals(fieldNames.length, schema.columns().size());
+        // identifier-field-ids must be empty — source PK must NOT be inherited
+        Assertions.assertTrue(
+                schema.identifierFieldIds().isEmpty(),
+                "identifier-field-ids must be empty when 
iceberg.table.primary-keys is not "
+                        + "configured, even if the source TableSchema has a 
primary key. "
+                        + "Inheriting the source PK silently activates 
BaseEqualityDeltaWriter "
+                        + "and causes positional deletes in append-only CDC 
pipelines.");
+    }
+
+    /**
+     * Guard against the regression introduced by the SchemaUtils PK-fallback 
fix: when {@code
+     * iceberg.table.upsert-mode-enabled=true} is set without an explicit 
{@code
+     * iceberg.table.primary-keys}, the sink must fail fast with a clear error 
rather than silently
+     * creating a delta writer with an empty identifier-field set (which would 
produce broken upsert
+     * semantics).
+     */
+    @Test
+    void testIcebergSinkConfigThrowsWhenUpsertModeEnabledWithoutPrimaryKeys() {
+        ReadonlyConfig config =
+                ReadonlyConfig.fromMap(
+                        new HashMap<String, Object>() {
+                            {
+                                
put(IcebergSinkOptions.TABLE_UPSERT_MODE_ENABLED_PROP.key(), true);
+                                // iceberg.table.primary-keys deliberately 
absent
+                            }
+                        });
+        IllegalArgumentException ex =
+                Assertions.assertThrows(
+                        IllegalArgumentException.class,
+                        () -> new IcebergSinkConfig(config),
+                        "IcebergSinkConfig must reject 
upsert-mode-enabled=true without an "
+                                + "explicit iceberg.table.primary-keys 
configuration");
+        Assertions.assertTrue(
+                
ex.getMessage().contains(IcebergSinkOptions.TABLE_PRIMARY_KEYS.key()),
+                "Error message should name the missing config key");
+    }
+
+    /**
+     * Regression test for the catalog-path CatalogTable PK fallback.
+     *
+     * <p>{@code SchemaUtils.autoCreateTable(Catalog, TablePath, CatalogTable, 
ReadonlyConfig)}
+     * overload 1 falls back to the CatalogTable's own primary key when {@code
+     * iceberg.table.primary-keys} is not in config. This is intentionally 
different from the CDC
+     * sink path ({@code toIcebergSchema(TableSchema, ReadonlyConfig)}) which 
must NOT inherit the
+     * source PK (see apache/seatunnel#10747).
+     *
+     * <p>This test exercises the schema-building half of that overload 
directly via the
+     * package-visible {@code toIcebergSchema(TableSchema, List)} overload to 
verify that the PK
+     * fallback correctly sets identifier-field-ids.
+     */
+    @Test
+    void testAutoCreateTableCatalogPathPreservesCatalogTablePk() {
+        List<Column> columns =
+                Arrays.asList(
+                        PhysicalColumn.of(
+                                "id", BasicType.LONG_TYPE, (Long) null, false, 
null, null),
+                        PhysicalColumn.of(
+                                "name", BasicType.STRING_TYPE, (Long) null, 
true, null, null),
+                        PhysicalColumn.of(
+                                "value", BasicType.STRING_TYPE, (Long) null, 
true, null, null));
+        PrimaryKey catalogPk = PrimaryKey.of("catalog_pk", 
Collections.singletonList("id"));
+        TableSchema tableSchema =
+                TableSchema.builder()
+                        .columns(columns)
+                        .primaryKey(catalogPk)
+                        .constraintKey(Collections.emptyList())
+                        .build();
+
+        // No TABLE_PRIMARY_KEYS in config — autoCreateTable overload 1 falls 
back to CatalogTable
+        // PK.
+        ReadonlyConfig configWithoutPkKey = ReadonlyConfig.fromMap(new 
HashMap<>());
+
+        // Replicate the catalog-path PK resolution logic from autoCreateTable 
overload 1.
+        List<String> pkColumns =
+                configWithoutPkKey
+                        .getOptional(IcebergSinkOptions.TABLE_PRIMARY_KEYS)
+                        .map(e -> IcebergSinkConfig.stringToList(e, ","))
+                        .orElseGet(
+                                () ->
+                                        
Optional.ofNullable(tableSchema.getPrimaryKey())
+                                                
.map(PrimaryKey::getColumnNames)
+                                                
.orElse(Collections.emptyList()));
+
+        // Call the package-private core method directly (same call 
autoCreateTable uses).
+        Schema schema = SchemaUtils.toIcebergSchema(tableSchema, pkColumns);
+
+        Assertions.assertFalse(
+                schema.identifierFieldIds().isEmpty(),
+                "autoCreateTable catalog path must preserve CatalogTable PK 
into "
+                        + "identifier-field-ids when 
iceberg.table.primary-keys is absent");
+        Assertions.assertEquals(
+                1,
+                schema.identifierFieldIds().size(),
+                "Only the single CatalogTable PK column (id) should be an 
identifier field");
+        Types.NestedField idField = schema.findField("id");
+        Assertions.assertNotNull(idField, "Field 'id' must exist in schema");
+        Assertions.assertTrue(
+                schema.identifierFieldIds().contains(idField.fieldId()),
+                "id field must be in identifier-field-ids");
+        Assertions.assertTrue(idField.isRequired(), "PK field 'id' must be 
marked required");
+
+        // Non-PK fields must remain optional.
+        Assertions.assertFalse(schema.findField("name").isRequired());
+        Assertions.assertFalse(schema.findField("value").isRequired());
     }
 }

Reply via email to