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

ferenc-csaky pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/flink-cdc.git


The following commit(s) were added to refs/heads/master by this push:
     new 017de09d2 [FLINK-40640][iceberg] Fix duplicate rows when an UPDATE 
changes the partition value (#4536)
017de09d2 is described below

commit 017de09d204450e4e0346035391688185666aee3
Author: Vishal <[email protected]>
AuthorDate: Fri Sep 18 10:39:02 2026 +0530

    [FLINK-40640][iceberg] Fix duplicate rows when an UPDATE changes the 
partition value (#4536)
---
 .../iceberg/sink/utils/RowDataUtils.java           | 39 +++++++----
 .../connectors/iceberg/sink/v2/IcebergWriter.java  |  6 +-
 .../iceberg/sink/v2/IcebergWriterTest.java         | 79 ++++++++++++++++++++++
 3 files changed, 107 insertions(+), 17 deletions(-)

diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java
index 32ffa4a0a..51b6a8595 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java
@@ -23,34 +23,43 @@ import org.apache.flink.table.data.GenericRowData;
 import org.apache.flink.table.data.RowData;
 import org.apache.flink.types.RowKind;
 
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 
 /** Utils for convertion of {@link RowData} and {@link DataChangeEvent}. */
 public class RowDataUtils {
 
-    /** Convert {@link DataChangeEvent} to {@link RowData}. */
-    public static RowData convertDataChangeEventToRowData(
+    /**
+     * Convert a {@link DataChangeEvent} to one or two {@link RowData}.
+     *
+     * <p>An {@code UPDATE} is split into a {@code DELETE} built from the 
before-image followed by
+     * an {@code INSERT} built from the after-image, rather than a single 
after-image row. Iceberg
+     * derives both the equality-delete key and the delete's target partition 
from the row passed to
+     * the writer; collapsing an UPDATE to just the after-image (as before) 
means a partition key
+     * change is never deleted from its old partition, leaving a stale 
duplicate behind.
+     */
+    public static List<RowData> convertDataChangeEventToRowData(
             DataChangeEvent dataChangeEvent, List<RecordData.FieldGetter> 
fieldGetters) {
-        RecordData recordData;
-        RowKind kind;
         switch (dataChangeEvent.op()) {
             case INSERT:
-            case UPDATE:
             case REPLACE:
-                {
-                    recordData = dataChangeEvent.after();
-                    kind = RowKind.INSERT;
-                    break;
-                }
+                return Collections.singletonList(
+                        toRowData(dataChangeEvent.after(), fieldGetters, 
RowKind.INSERT));
             case DELETE:
-                {
-                    recordData = dataChangeEvent.before();
-                    kind = RowKind.DELETE;
-                    break;
-                }
+                return Collections.singletonList(
+                        toRowData(dataChangeEvent.before(), fieldGetters, 
RowKind.DELETE));
+            case UPDATE:
+                return Arrays.asList(
+                        toRowData(dataChangeEvent.before(), fieldGetters, 
RowKind.DELETE),
+                        toRowData(dataChangeEvent.after(), fieldGetters, 
RowKind.INSERT));
             default:
                 throw new IllegalArgumentException("don't support type of " + 
dataChangeEvent.op());
         }
+    }
+
+    private static RowData toRowData(
+            RecordData recordData, List<RecordData.FieldGetter> fieldGetters, 
RowKind kind) {
         GenericRowData genericRowData = new 
GenericRowData(recordData.getArity());
         genericRowData.setRowKind(kind);
         for (int i = 0; i < recordData.getArity(); i++) {
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java
index 9af29d88c..6945ed57f 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java
@@ -166,10 +166,12 @@ public class IcebergWriter
                     writerMap.computeIfAbsent(
                             tableId, tableId1 -> 
writerFactoryMap.get(tableId1).create());
             TableSchemaWrapper tableSchemaWrapper = schemaMap.get(tableId);
-            RowData rowData =
+            List<RowData> rowDataList =
                     RowDataUtils.convertDataChangeEventToRowData(
                             dataChangeEvent, 
tableSchemaWrapper.getFieldGetters());
-            writer.write(rowData);
+            for (RowData rowData : rowDataList) {
+                writer.write(rowData);
+            }
         } else {
             SchemaChangeEvent schemaChangeEvent = (SchemaChangeEvent) event;
             TableId tableId = schemaChangeEvent.tableId();
diff --git 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterTest.java
 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterTest.java
index 71e08de4c..4eda834ed 100644
--- 
a/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterTest.java
+++ 
b/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/test/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriterTest.java
@@ -1568,6 +1568,85 @@ public class IcebergWriterTest {
                 .containsExactlyInAnyOrder("1, c, null, null", "2, d, null, 
null");
     }
 
+    /**
+     * Verifies the core partition-key-update bug: when an UPDATE changes the 
value of a partitioned
+     * column, the before-image must be deleted from its original partition, 
not just the
+     * after-image inserted into the new one. Iceberg's equality deletes are 
partition-scoped —
+     * deriving the delete purely from the after-image (the pre-fix behavior) 
routes it to the new
+     * partition and never touches the old one, leaving the stale row behind 
and producing a
+     * duplicate primary key on read.
+     */
+    @Test
+    public void testUpdateChangingPartitionValueDeletesFromOldPartition() 
throws Exception {
+        Map<String, String> catalogOptions = new HashMap<>();
+        catalogOptions.put("type", "hadoop");
+        catalogOptions.put(
+                "warehouse",
+                new File(temporaryFolder.toFile(), 
UUID.randomUUID().toString()).toString());
+        catalogOptions.put("cache-enabled", "false");
+        Catalog catalog =
+                CatalogUtil.buildIcebergCatalog(
+                        "cdc-iceberg-catalog", catalogOptions, new 
Configuration());
+        IcebergMetadataApplier icebergMetadataApplier = new 
IcebergMetadataApplier(catalogOptions);
+
+        String jobId = UUID.randomUUID().toString();
+        String operatorId = UUID.randomUUID().toString();
+        IcebergWriter icebergWriter =
+                new IcebergWriter(
+                        catalogOptions,
+                        1,
+                        1,
+                        ZoneId.systemDefault(),
+                        0,
+                        jobId,
+                        operatorId,
+                        new HashMap<>());
+
+        TableId tableId = TableId.parse("test.iceberg_table");
+        Schema schema =
+                Schema.newBuilder()
+                        .physicalColumn("id", DataTypes.BIGINT().notNull())
+                        .physicalColumn("region", 
DataTypes.VARCHAR(100).notNull())
+                        .primaryKey("id")
+                        .partitionKey("region")
+                        .build();
+        CreateTableEvent createTableEvent = new CreateTableEvent(tableId, 
schema);
+        icebergMetadataApplier.applySchemaChange(createTableEvent);
+        icebergWriter.write(createTableEvent, null);
+
+        BinaryRecordDataGenerator generator =
+                new 
BinaryRecordDataGenerator(schema.getColumnDataTypes().toArray(new DataType[0]));
+        RecordData north =
+                generator.generate(new Object[] {1L, 
BinaryStringData.fromString("north")});
+        RecordData east =
+                generator.generate(new Object[] {1L, 
BinaryStringData.fromString("east")});
+
+        // INSERT(id=1, region="north") is committed as its own snapshot, 
landing in a real,
+        // already-closed data file under the region=north partition.
+        icebergWriter.write(DataChangeEvent.insertEvent(tableId, north), null);
+        Collection<WriteResultWrapper> insertResults = 
icebergWriter.prepareCommit();
+        IcebergCommitter icebergCommitter = new 
IcebergCommitter(catalogOptions, new HashMap<>());
+        icebergCommitter.commit(
+                insertResults.stream()
+                        .map(MockCommitRequestImpl::new)
+                        .collect(Collectors.toList()));
+
+        // UPDATE(id=1, region: "north" -> "east") moves the row to a 
different partition.
+        icebergWriter.write(DataChangeEvent.updateEvent(tableId, north, east), 
null);
+        Collection<WriteResultWrapper> updateResults = 
icebergWriter.prepareCommit();
+        icebergCommitter.commit(
+                updateResults.stream()
+                        .map(MockCommitRequestImpl::new)
+                        .collect(Collectors.toList()));
+        icebergCommitter.close();
+
+        // Only "1, east" must survive. Pre-fix, "1, north" also remained: the 
equality-delete
+        // was derived solely from the after-image and routed to region=east, 
never touching the
+        // region=north partition holding the original row.
+        List<String> result = fetchTableContent(catalog, tableId, null);
+        Assertions.assertThat(result).containsExactly("1, east");
+    }
+
     private static long countSnapshots(Table table) {
         long count = 0;
         for (Snapshot ignored : table.snapshots()) {

Reply via email to